검색
카테고리
- 한눈에 보는 Effective Java Java java effective-java 한눈에 보는 Effective Java 한눈에 보는 Effective JavaEffective Java 3판의 90개 아이템을 장별로 정리한 글이다. 각 아이템의 핵심 원리와 실전 코드를 담았다.2장. 객체 생성과 파괴객체를 만들어야 할 때와 만들지 말아야 할 때, 올바른 파괴 방법, 그리고 생성 전 처리를 다룬다.Item 1. 생성자 대신 static factory method를 고려하라클래스의 인스턴스를 얻는 수단이 꼭 public 생성자일 필요는 없다. static factory method는 다섯 가지 장점이 있다.이름을 가질 수 있다. BigInteger.probablePrime()처럼 반환될 객체의 특성을 이름으로 표현할 수 있다. 같은 시그니처의 생성자가 여러 개 필요하면 정적 팩터리로 바꾸고 이름을 붙여라.호출할 때마다 새 인스턴스를 만들지 않아도 된다. Boolean.valueOf(boolean)은 객체를 새로 만들지 않고 미리 만든 상수를 반환한다. 플라이웨이트 패턴과 같은 맥락이다.반환 타입의 하위 타입 객체를 돌려줄 수 있다. Collections.unmodifiableList()처럼 구현 클래스를 숨기고 인터페이스만 노출한다. API가 작아지고, 사용자는 구현이 아닌 인터페이스에 의존하게 된다.입력 매개변수에 따라 매번 다른 클래스의 객체를 반환할 수 있다. 반환 타입의 하위 타입이기만 하면 어떤 클래스의 객체든 반환할 수 있다. EnumSet은 원소 수에 따라 RegularEnumSet 또는 JumboEnumSet을 반환한다. 클라이언트는 이 두 클래스의 존재를 모르며, 알 필요도 없다.// 입력에 따라 다른 하위 타입 반환public static <E extends Enum<E>> EnumSet<E> noneOf(Class<E> elementType) { if (universe.length <= 64) return new RegularEnumSet<>(elementType, universe); else return new JumboEnumSet<>(elementType, universe);}정적 팩터리 메서드를 작성하는 시점에는 반환할 객체의 클래스가 존재하지 않아도 된다. 이 유연함이 서비스 제공자 프레임워크(service provider framework)의 근간이다. JDBC가 대표적인 예로, DriverManager.getConnection()은 각 DB 벤더가 제공하는 Driver 구현체를 런타임에 로딩한다. Java 6부터는 ServiceLoader가 범용 서비스 제공자 프레임워크 역할을 한다.정적 팩터리 명명 규칙: 이름 용도 예시 from 매개변수 하나, 형변환 Date.from(instant) of 매개변수 여러 개, 집계 EnumSet.of(JACK, QUEEN) valueOf from/of의 자세한 버전 BigInteger.valueOf(42L) instance / getInstance 인스턴스 반환 (같을 수 있음) StackWalker.getInstance(opt) create / newInstance 항상 새 인스턴스 Array.newInstance(cls, 10) 단점도 있다. 하위 클래스를 만들려면 public/protected 생성자가 있어야 하고, 프로그래머가 찾기 어렵다. API 문서에 명확히 드러나지 않기 때문이다. 명명 규칙(from, of, valueOf 등)을 지키면 이 문제를 완화할 수 있다.실무에서 정적 팩터리가 많이 쓰이는 예로 List.of(), Map.of(), Optional.of(), Collections.unmodifiableList() 등이 있다. Spring 프레임워크의 BeanFactory가 정적 팩터리 패턴의 확장된 형태라 볼 수 있다.Item 2. 생성자에 매개변수가 많다면 Builder를 고려하라매개변수가 많으면 점층적 생성자(telescoping constructor)나 JavaBeans 패턴은 한계가 있다. Builder 패턴은 둘의 장점을 결합한다.NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8) // 필수 .calories(100) // 선택 .sodium(35) // 선택 .carbohydrate(27) // 선택 .build();빌더 패턴의 흐름을 도식화하면 이렇다.flowchart LR A["Builder 생성<br/>(필수 매개변수)"] --> B["setter 체이닝<br/>(선택 매개변수)"] B --> C["build() 호출<br/>(불변 객체 반환)"] C --> D["✅ 완성된 불변 객체"]빌더 패턴은 계층 구조에서 특히 빛난다. 추상 빌더의 하위에 구체 빌더를 두고, 재귀적 타입 파라미터 Builder<T extends Builder<T>>와 공변 반환 타이핑(covariant return typing)을 결합하면 하위 클래스의 빌더가 상위 빌더 타입을 반환하지 않아도 된다.// 계층적 빌더 패턴public abstract class Pizza { public enum Topping { HAM, MUSHROOM, ONION, PEPPER, SAUSAGE } final Set<Topping> toppings; abstract static class Builder<T extends Builder<T>> { EnumSet<Topping> toppings = EnumSet.noneOf(Topping.class); public T addTopping(Topping topping) { toppings.add(Objects.requireNonNull(topping)); return self(); } abstract Pizza build(); protected abstract T self(); // 하위 클래스가 this를 반환 } Pizza(Builder<?> builder) { toppings = builder.toppings.clone(); }}public class NyPizza extends Pizza { public enum Size { SMALL, MEDIUM, LARGE } private final Size size; public static class Builder extends Pizza.Builder<Builder> { private final Size size; public Builder(Size size) { this.size = Objects.requireNonNull(size); } @Override public NyPizza build() { return new NyPizza(this); } @Override protected Builder self() { return this; } } private NyPizza(Builder builder) { super(builder); size = builder.size; }}// 사용: 하위 타입 그대로 반환 → 캐스팅 불필요NyPizza pizza = new NyPizza.Builder(SMALL) .addTopping(SAUSAGE) .addTopping(ONION) .build();매개변수가 4개를 넘기기 시작하면 빌더를 고려하라. 처음부터 빌더로 시작하는 편이 나을 때가 많다.Item 3. private 생성자나 열거 타입으로 singleton임을 보증하라싱글턴을 만드는 세 가지 방법이 있다.// 방식 1: public static final 필드public class Elvis { public static final Elvis INSTANCE = new Elvis(); private Elvis() { }}// 방식 2: 정적 팩터리public class Elvis { private static final Elvis INSTANCE = new Elvis(); public static Elvis getInstance() { return INSTANCE; } private Elvis() { }}// 방식 3: 열거 타입 — 가장 좋은 방법public enum Elvis { INSTANCE; public void leaveTheBuilding() { ... }}열거 타입 방식은 직렬화와 리플렉션 공격을 완벽히 방어한다. 다만 Enum 이외의 클래스를 상속해야 하면 사용할 수 없다.방식 1, 2는 리플렉션으로 private 생성자를 호출할 수 있으므로 생성자에서 두 번째 호출을 차단해야 한다. 직렬화 시에는 readResolve() 메서드를 제공하지 않으면 역직렬화 때마다 새 인스턴스가 만들어진다.실무에서 싱글턴 선택 가이드: 상황 추천 방식 가장 간단하고 안전 열거 타입 (Item 3 방식 3) API 변경 유연성 우선 정적 팩터리 (Item 3 방식 2) Enum 외 상속 필요 방식 1 또는 2 + readResolve Item 4. 인스턴스화를 막으려거든 private 생성자를 사용하라java.lang.Math, java.util.Collections처럼 정적 멤버만 모아둔 유틸리티 클래스는 인스턴스를 만들 이유가 없다.public class UtilityClass { private UtilityClass() { throw new AssertionError(); // 실수로라도 내부 호출 방지 }}생성자를 명시하지 않으면 컴파일러가 기본 생성자를 만들어버린다. 추상 클래스로는 인스턴스화를 막을 수 없다 — 하위 클래스를 만들면 그만이다. private 생성자는 상속까지 차단하는 부수효과가 있다.Item 5. 자원을 직접 명시하지 말고 의존 객체 주입을 사용하라사용하는 자원에 따라 동작이 달라지는 클래스에는 정적 유틸리티나 싱글턴이 맞지 않는다.flowchart LR subgraph 나쁜설계["❌ 자원 직접 지정"] A[SpellChecker] -- "직접 생성" --> B[KoreanDictionary] end subgraph 좋은설계["✅ 의존 객체 주입"] C[SpellChecker] -- "생성자로 주입" --> D["Lexicon (인터페이스)"] D -.- E[KoreanDictionary] D -.- F[EnglishDictionary] D -.- G[TestDictionary] end// 나쁜 예: 자원을 직접 명시public class SpellChecker { private static final Lexicon dictionary = new KoreanDictionary(); // 다른 사전으로 교체 불가, 테스트 어려움}// 좋은 예: 의존 객체 주입public class SpellChecker { private final Lexicon dictionary; public SpellChecker(Lexicon dictionary) { this.dictionary = Objects.requireNonNull(dictionary); }}이 패턴의 변형으로 생성자에 자원 팩터리를 넘길 수 있다. Supplier<? extends Tile>처럼 한정적 와일드카드 타입으로 팩터리의 타입 매개변수를 제한하면 된다. 의존성이 많으면 Spring, Guice 같은 DI 프레임워크를 쓰자.Item 6. 불필요한 객체 생성을 피하라같은 기능의 객체를 매번 생성하는 대신 하나를 재사용하는 편이 낫다.// 매번 Pattern 컴파일 — 성능 병목static boolean isRomanSlow(String s) { return s.matches("^(?=.)M*(C[MD]|D?C{0,3})...");}// Pattern 캐싱 — 반복 호출에서 눈에 띄는 비용 절감private static final Pattern ROMAN = Pattern.compile("^(?=.)M*(C[MD]|D?C{0,3})...");static boolean isRomanFast(String s) { return ROMAN.matcher(s).matches();}오토박싱도 주의해야 한다.Long sum = 0L; // Long → 반복 덧셈마다 불필요한 박싱 객체 생성long sum = 0L; // long → 장기 루프에서 훨씬 안정적이고 효율적“객체 생성은 비싸니 무조건 피하라“는 뜻이 아니다. 방어적 복사가 필요한 곳에서 객체 재사용은 버그와 보안 구멍으로 이어진다. 반대로 불필요한 객체 생성은 코드 형태와 성능에만 영향을 준다.특히 주의할 실수 패턴:// 1. String 생성자 사용 — 절대 하지 말 것new String("hello"); // 새 인스턴스 생성"hello"; // 문자열 풀에서 재사용// 2. Boolean 생성자 — Java 9에서 마침내 deprecatednew Boolean("true"); // 매번 새 객체Boolean.valueOf("true"); // 캐시된 인스턴스 재사용// 3. Map.keySet() — 매번 같은 객체 반환 (재생성 없음)Item 7. 다 쓴 객체 참조를 해제하라GC가 있어도 메모리 누수는 일어난다. 대표적인 예가 Stack이다.public Object pop() { if (size == 0) throw new EmptyStackException(); Object result = elements[--size]; elements[size] = null; // 다 쓴 참조 해제 return result;}null 처리를 안 하면 elements 배열의 비활성 영역이 그대로 참조를 유지하여 GC가 회수하지 못한다.메모리 누수의 주범 세 가지:flowchart TD A["🔍 메모리 누수 주범"] --> B["자기 메모리를 직접 관리하는 클래스"] A --> C["캐시"] A --> D["리스너 / 콜백"] B --> B1["해법: 다 쓴 참조를 null 처리"] C --> C1["해법: WeakHashMap 또는<br/>LinkedHashMap.removeEldestEntry"] D --> D1["해법: WeakReference로 저장"]자기 메모리를 직접 관리하는 클래스란, 원소를 담은 배열의 ‘활성 영역’과 ‘비활성 영역’을 GC가 알 수 없는 경우를 말한다. Stack 예시처럼 비활성 영역의 원소를 null로 밀어야 GC가 회수할 수 있다.캐시 메모리 누수는 엔트리의 유효 기간이 명확하지 않은 경우에 발생한다. 키를 외부에서 참조하는 동안만 엔트리가 유효하다면 WeakHashMap을 쓰라. 키에 대한 강한 참조가 사라져 GC 대상이 되면, 이후 Map의 내장 메서드(get, put 등)가 호출될 때 내부적으로 해당 엔트리를 제거한다. 백그라운드 스레드(ScheduledThreadPoolExecutor) 또는 새 엔트리 추가 시 부수 작업으로 정리하는 방식도 있다.null 처리는 예외적 상황에서만 하고, 가장 좋은 방법은 변수의 유효 범위를 최소화하는 것이다(Item 57).Item 8. finalizer와 cleaner 사용을 피하라finalizer는 예측 불가능하고, 느리고, 위험하다. cleaner는 낫지만 여전히 예측 불가능하고 느리다. 즉시 수행을 보장하지 않는다 — GC 알고리즘에 전적으로 의존 심각한 성능 문제 — 명시적 자원 해제보다 훨씬 느리고 예측하기 어렵다 finalizer 공격 — 생성자에서 예외가 발생해도 악의적 하위 클래스의 finalizer가 수행될 수 있음대안은 AutoCloseable을 구현하고 try-with-resources를 쓰는 것이다. cleaner는 close 호출을 잊었을 때의 안전망이나 네이티브 피어(native peer) 회수 정도에만 쓴다.finalizer 공격의 원리: 생성자에서 예외가 발생하면 객체가 완전히 생성되지 않지만, 악의적 하위 클래스의 finalizer가 수행될 수 있다. 이 finalizer에서 자신의 참조를 정적 필드에 저장하면 GC가 수거하지 못하게 되어 객체가 되살아날 수 있다.// finalizer 공격 방어: final 클래스로 만들거나, 빈 finalize() 메서드를 final로 선언public class Foo { // 하위 클래스가 finalize를 재정의하지 못하게 방어 @Override protected final void finalize() { }}Item 9. try-finally보다는 try-with-resources를 사용하라AutoCloseable 인터페이스를 구현한 자원을 회수할 때는 예외 없이(반드시) try-with-resources를 쓰라.// try-finally: 자원이 둘이면 중첩이 필요하고 예외가 삼켜질 수 있음static void copy(String src, String dst) throws IOException { InputStream in = new FileInputStream(src); try { OutputStream out = new FileOutputStream(dst); try { byte[] buf = new byte[BUFFER_SIZE]; int n; while ((n = in.read(buf)) >= 0) out.write(buf, 0, n); } finally { out.close(); } } finally { in.close(); }}// try-with-resources: 짧고, 예외 정보가 정확하고, 안전함static void copy(String src, String dst) throws IOException { try (InputStream in = new FileInputStream(src); OutputStream out = new FileOutputStream(dst)) { byte[] buf = new byte[BUFFER_SIZE]; int n; while ((n = in.read(buf)) >= 0) out.write(buf, 0, n); }}try 블록과 close 양쪽에서 예외가 발생하면, try-with-resources는 close의 예외를 suppressed로 기록하고 try의 예외를 상위로 전파한다. Throwable.getSuppressed()로 꺼내볼 수 있다.flowchart LR subgraph tryfinally["try-finally"] direction TB TF1["try 예외"] --> TF2["close 예외"] TF2 --> TF3["❌ try 예외가 삼켜짐<br/>close 예외만 전파"] end subgraph twr["try-with-resources"] direction TB TW1["try 예외"] --> TW2["close 예외"] TW2 --> TW3["✅ try 예외 전파<br/>close는 suppressed로 기록"] endcatch 절도 함께 쓸 수 있어서, 다수의 예외를 우아하게 처리할 수 있다.static String firstLineOfFile(String path, String defaultVal) { try (BufferedReader br = new BufferedReader(new FileReader(path))) { return br.readLine(); } catch (IOException e) { return defaultVal; // 예외 시 기본값 반환 }}3장. 모든 객체의 공통 메서드Object의 equals, hashCode, toString, clone, compareTo를 올바르게 재정의하는 방법이다.Item 10. equals는 일반 규약을 지켜 재정의하라equals를 재정의하지 않는 게 최선인 경우가 많다. 각 인스턴스가 본질적으로 고유하거나, 논리적 동치성 검사가 필요 없거나, 상위 클래스의 equals가 이미 적절하면 재정의하지 마라.재정의할 때는 다섯 가지 규약을 반드시 지켜야 한다.flowchart TB subgraph equals규약["equals 일반 규약"] direction TB R["1️⃣ 반사성 Reflexivity<br/>x.equals(x) == true"] S["2️⃣ 대칭성 Symmetry<br/>x.equals(y) ↔ y.equals(x)"] T["3️⃣ 추이성 Transitivity<br/>x=y, y=z → x=z"] C["4️⃣ 일관성 Consistency<br/>같은 입력 → 항상 같은 결과"] N["5️⃣ null-아님<br/>x.equals(null) == false"] end대칭성 위반의 대표 사례: CaseInsensitiveString이 String과의 비교를 지원하면, 반대쪽 String.equals()는 CaseInsensitiveString을 모르기 때문에 대칭성이 깨진다.// 대칭성 위반 예시CaseInsensitiveString cis = new CaseInsensitiveString("Polish");String s = "polish";cis.equals(s); // true — CaseInsensitiveString 쪽은 String 비교를 허용s.equals(cis); // false — 대칭성 위반!추이성 위반의 핵심: 구체 클래스를 확장해 새 값 컴포넌트(필드)를 추가하면 equals 규약을 만족시키는 방법이 존재하지 않는다. Point를 상속한 ColorPoint가 대표적인 사례다. instanceof 검사를 getClass() 검사로 바꾸면 추이성은 지키지만 리스코프 치환 원칙을 위배한다.구체 클래스를 확장해 새 값 컴포넌트를 추가하면서 equals 규약을 만족시킬 방법은 없다. 이 문제의 해법은 상속 대신 컴포지션을 쓰는 것이다(Item 18).올바른 equals 구현 레시피:@Overridepublic boolean equals(Object o) { if (this == o) return true; // 1. 참조 비교 if (!(o instanceof PhoneNumber)) return false; // 2. 타입 검사 + null 검사 PhoneNumber pn = (PhoneNumber) o; // 3. 형변환 return pn.lineNum == lineNum // 4. 핵심 필드 비교 && pn.prefix == prefix && pn.areaCode == areaCode;}float/double은 Float.compare(), Double.compare()로 비교하고, 배열은 Arrays.equals()를 쓴다. 비교 비용이 싼 필드부터 먼저 비교하면 성능이 좋아진다.Java 7+에서는 Objects.equals(a, b)를 활용하면 null 검사를 간결하게 처리할 수 있다. IDE(예: IntelliJ)나 AutoValue, Lombok이 생성하는 equals가 사람이 직접 작성한 것보다 대체로 더 안전하다. Java 16+의 Record를 쓰면 equals, hashCode, toString이 자동 생성된다.Item 11. equals를 재정의하려거든 hashCode도 재정의하라equals가 같은 두 객체는 반드시 같은 hashCode를 반환해야 한다. 이를 어기면 HashMap, HashSet이 오동작한다.// 끔찍한 구현 — 합법이지만 모든 객체가 같은 해시 버킷에 들어감@Override public int hashCode() { return 42; }// 올바른 구현@Overridepublic int hashCode() { int result = Short.hashCode(areaCode); result = 31 * result + Short.hashCode(prefix); result = 31 * result + Short.hashCode(lineNum); return result;}31을 곱하는 이유는 홀수인 소수이며 31 * i == (i << 5) - i로 JVM이 최적화할 수 있기 때문이다.hashCode를 제대로 구현하지 않으면 어떤 일이 벌어지는가:Map<PhoneNumber, String> map = new HashMap<>();map.put(new PhoneNumber(707, 867, 5309), "Jenny");map.get(new PhoneNumber(707, 867, 5309)); // hashCode 미구현 시 null 반환!// put할 때의 객체와 get할 때의 객체가 다른 해시 버킷에 들어감Objects.hash(areaCode, prefix, lineNum)를 쓰면 한 줄로 끝나지만, 성능이 민감하면 직접 계산하라 — Objects.hash는 내부에서 배열을 만들고 오토박싱이 발생한다.성능이 민감하면 해시값을 캐싱하라. 지연 초기화(lazy initialization)도 가능하다.private int hashCode; // 기본값 0@Overridepublic int hashCode() { int result = hashCode; if (result == 0) { result = Short.hashCode(areaCode); result = 31 * result + Short.hashCode(prefix); result = 31 * result + Short.hashCode(lineNum); hashCode = result; } return result;}equals에 사용하지 않는 필드는 hashCode 계산에서도 반드시 제외해야 한다.Item 12. toString을 항상 재정의하라기본 Object.toString()은 클래스명@16진수해시코드를 반환한다. 이는 디버깅과 로깅에서 쓸모가 없다.toString은 그 객체가 가진 주요 정보를 모두 반환해야 한다. 포맷을 명시했든 아니든, 의도를 명확히 문서화하라.// 포맷 명시@Overridepublic String toString() { return String.format("%03d-%04d-%04d", areaCode, prefix, lineNum);}// 포맷 비명시@Overridepublic String toString() { return "PhoneNumber{areaCode=" + areaCode + ", prefix=" + prefix + ", lineNum=" + lineNum + "}";}순환 참조가 있는 객체(A → B → A)에서 상대를 toString()에 포함하면 StackOverflowError가 발생한다. 주의하라.실무 팁: 로그에서 toString()은 매우 자주 호출된다. 비밀번호 같은 민감 정보는 절대 포함하지 마라. Lombok의 @ToString(exclude = "password")나 직접 제어로 해결한다.Item 13. clone 재정의는 주의해서 진행하라Cloneable은 문제가 많은 인터페이스다. clone()의 규약은 허술하고, 가변 객체를 참조하면 원본과 복제본이 같은 내부 상태를 공유하게 된다.// 가변 상태가 있는 클래스의 clone@Overridepublic Stack clone() { try { Stack result = (Stack) super.clone(); result.elements = elements.clone(); // 배열의 clone은 권장되는 유일한 용법 return result; } catch (CloneNotSupportedException e) { throw new AssertionError(); }}연결 리스트 같은 깊은 구조는 재귀적 clone으로 stack overflow가 날 수 있으므로 반복문으로 깊은 복사를 해야 한다.더 나은 대안은 복사 생성자와 복사 팩터리다.public Yum(Yum yum) { ... } // 복사 생성자public static Yum newInstance(Yum yum) { ... } // 복사 팩터리이 방식은 Cloneable/clone의 모든 문제에서 자유롭고, 인터페이스 타입의 인스턴스도 인수로 받을 수 있다. 예: new TreeSet<>(hashSet).Item 14. Comparable을 구현할지 고려하라자연적 순서가 있는 값 클래스를 작성한다면 Comparable을 구현하라. TreeSet, TreeMap, Collections.sort()가 바로 동작한다.// 기본 비교@Overridepublic int compareTo(PhoneNumber pn) { int result = Short.compare(areaCode, pn.areaCode); if (result == 0) { result = Short.compare(prefix, pn.prefix); if (result == 0) result = Short.compare(lineNum, pn.lineNum); } return result;}// Comparator 빌더 — 더 깔끔함private static final Comparator<PhoneNumber> COMPARATOR = comparingInt((PhoneNumber pn) -> pn.areaCode) .thenComparingInt(pn -> pn.prefix) .thenComparingInt(pn -> pn.lineNum);@Overridepublic int compareTo(PhoneNumber pn) { return COMPARATOR.compare(this, pn);}절대로 return o1.hashCode() - o2.hashCode() 같은 뺄셈 기반 비교를 하지 마라. 정수 오버플로가 발생한다. Integer.compare()나 Comparator.comparingInt()를 써라.flowchart TD Q{"값 클래스에 순서가 있는가?"} Q -- "네" --> C["Comparable 구현"] Q -- "아니오" --> N["구현 불필요"] C --> M{"필드가 여러 개인가?"} M -- "1개" --> S["Short.compare / Integer.compare"] M -- "여러 개" --> B["Comparator.comparingInt().thenComparing..."]핵심: compareTo의 반환값이 0인 두 객체는 equals도 true여야 한다(권장). BigDecimal은 이 권장을 어겨서 new BigDecimal("1.0")과 new BigDecimal("1.00")이 compareTo는 0이지만 equals는 false다. HashSet에는 둘 다 들어가지만 TreeSet에는 하나만 들어간다.4장. 클래스와 인터페이스Item 15. 클래스와 멤버의 접근 권한을 최소화하라잘 설계된 컴포넌트는 내부 구현을 완벽히 숨기고, API와 구현을 깔끔히 분리한다. 이것이 정보 은닉(information hiding)이자 캡슐화(encapsulation)다.원칙은 단순하다: 모든 클래스와 멤버의 접근성을 가능한 한 좁혀라.flowchart LR subgraph 접근범위["접근 제어자 범위 (좁은 → 넓은)"] direction LR A["private<br/>선언한 클래스 안"] --> B["package-private<br/>같은 패키지 (기본값)"] B --> C["protected<br/>+ 하위 클래스"] C --> D["public<br/>모든 곳"] end접근성을 좁혀야 하는 이유: 내부 구현을 숨기면 시스템을 구성하는 컴포넌트를 독립적으로 개발·테스트·최적화·교체할 수 있다. 이를 정보 은닉(information hiding)이라 하며, 소프트웨어 설계의 근간이다.멤버 접근성을 좁히지 못하게 방해하는 제약이 하나 있다. 상위 클래스의 메서드를 재정의할 때, 접근 수준을 상위 클래스보다 좁게 설정할 수 없다(리스코프 치환 원칙). 이 제약 때문에 인터페이스를 구현하는 클래스는 인터페이스의 메서드를 모두 public으로 선언해야 한다.public 클래스의 인스턴스 필드는 되도록 public이 아니어야 한다. public static final 상수만 예외이며, 이 경우도 기본 타입이나 불변 객체만 참조해야 한다. 가변 객체를 참조하는 public static final 필드는 보안 구멍이다.// 나쁜 예 — 배열은 가변이라 외부에서 수정 가능public static final Thing[] VALUES = { ... };// 해결 1: 불변 리스트public static final List<Thing> VALUES = Collections.unmodifiableList(Arrays.asList(PRIVATE_VALUES));// 해결 2: 방어적 복사public static final Thing[] values() { return PRIVATE_VALUES.clone(); }Item 16. public 클래스에서는 public 필드가 아닌 접근자 메서드를 사용하라패키지 바깥에서 접근할 수 있는 클래스라면 접근자(getter)를 제공하라. 필드를 직접 노출하면 내부 표현을 바꿀 수 없고, 불변식을 보장할 수 없다.package-private 클래스나 private 중첩 클래스에서는 필드를 직접 노출해도 무방하다 — 수정 범위가 한정되기 때문이다.Item 17. 변경 가능성을 최소화하라불변 클래스를 만드는 다섯 가지 규칙:flowchart TB subgraph 불변규칙["불변 클래스 5대 규칙"] direction TB R1["1. setter 제공 금지"] R2["2. 확장 불가 (final 클래스 or private 생성자 + 정적 팩터리)"] R3["3. 모든 필드 final 선언"] R4["4. 모든 필드 private 선언"] R5["5. 가변 컴포넌트 접근 차단 (방어적 복사)"] end 불변규칙 --> 결과["✅ Thread-safe, 공유 안전, Map 키로 적합"]불변 객체의 장점: 단순하고, thread-safe하며(동기화 불필요), 안심하고 공유할 수 있고, Map 키와 Set 원소로 안전하다.단점은 값이 다르면 반드시 독립된 객체를 만들어야 한다는 것이다. 이를 가변 동반 클래스(companion class)로 보완한다. String의 가변 동반이 StringBuilder다.public final class Complex { private final double re; private final double im; public Complex plus(Complex c) { return new Complex(re + c.re, im + c.im); // 새 객체 반환, this 불변 }}함수형 프로그래밍 스타일로, 값을 변경하는 메서드 이름은 add 대신 plus처럼 전치사를 사용한다.불변 객체의 성능 이슈로 다단계 연산(multistep operation)이 많으면 가변 동반 클래스를 제공하라: 불변 클래스 가변 동반 클래스 용도 String StringBuilder 문자열 조합 BigInteger (package-private) 다단계 산술 ImmutableList (Guava) ImmutableList.Builder 리스트 조립 불변 클래스를 final로 만드는 대신, 모든 생성자를 private 또는 package-private으로 만들고 public 정적 팩터리를 제공하는 방식이 더 유연하다. 나중에 캐싱을 추가하거나 하위 클래스를 만들 수 있기 때문이다.Item 18. 상속보다는 컴포지션을 사용하라패키지 경계를 넘어 다른 구체 클래스를 상속하는 ‘구현 상속(Implementation Inheritance)’은 캡슐화를 깨뜨릴 수 있다. 상위 클래스가 내부 구현을 바꾸면 하위 클래스가 오동작할 수 있다.// 상속의 문제 — addAll이 내부에서 add를 호출하므로 addCount가 두 배가 됨public class InstrumentedHashSet<E> extends HashSet<E> { private int addCount = 0; @Override public boolean add(E e) { addCount++; return super.add(e); } @Override public boolean addAll(Collection<? extends E> c) { addCount += c.size(); return super.addAll(c); // 내부에서 this.add()를 호출 → addCount 또 증가 }}해법은 컴포지션 + 전달(forwarding)이다. 기존 클래스를 private 필드로 참조하고 메서드를 위임한다.classDiagram class Set { <<interface>> +add(E e) +addAll(Collection c) } class ForwardingSet { -Set s +add(E e) s.add(e) +addAll(Collection c) s.addAll(c) } class InstrumentedSet { -int addCount +add(E e) count++, super.add(e) +addAll(Collection c) count+=c.size(), super.addAll(c) } class HashSet Set <|.. ForwardingSet : implements ForwardingSet <|-- InstrumentedSet : extends ForwardingSet o-- Set : delegates to Set <|.. HashSet : implements// 전달 클래스 (재사용 가능)public class ForwardingSet<E> implements Set<E> { private final Set<E> s; public ForwardingSet(Set<E> s) { this.s = s; } public boolean add(E e) { return s.add(e); } public boolean addAll(Collection<? extends E> c) { return s.addAll(c); } // ... 나머지 Set 메서드도 위임}// 래퍼 클래스 (데코레이터)public class InstrumentedSet<E> extends ForwardingSet<E> { private int addCount = 0; public InstrumentedSet(Set<E> s) { super(s); } @Override public boolean add(E e) { addCount++; return super.add(e); } @Override public boolean addAll(Collection<? extends E> c) { addCount += c.size(); return super.addAll(c); // ForwardingSet.addAll → s.addAll → 정확 }}상속은 is-a 관계일 때만, 그리고 상위 클래스의 API에 결함이 없을 때만 써라.실제 JDK에서의 위반 사례: Stack extends Vector, Properties extends Hashtable는 모두 is-a 관계가 아닌데 상속을 사용한 경우다. Properties의 getProperty(key)는 Hashtable의 get(key)과 다른 결과를 낼 수 있어 혼란을 준다.Item 19. 상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라상속용 클래스는 재정의 가능 메서드의 자기사용(self-use) 패턴을 문서화해야 한다. 생성자에서 재정의 가능 메서드를 호출하면 안 된다.public class Super { public Super() { overrideMe(); } // 위험! public void overrideMe() { }}public final class Sub extends Super { private final Instant instant; Sub() { instant = Instant.now(); } @Override public void overrideMe() { System.out.println(instant); // 첫 호출 시 null — 아직 Sub 생성자 실행 전 }}상속용으로 설계하지 않은 클래스는 final로 선언하거나, 생성자를 private으로 만들고 정적 팩터리를 제공하여 상속을 금지하라.Item 20. 추상 클래스보다는 인터페이스를 우선하라인터페이스의 강점 세 가지: 기존 클래스에 쉽게 끼워 넣을 수 있다 — implements Comparable만 추가하면 됨 믹스인(mixin) 정의에 적합하다 — 주된 기능 외에 Comparable, Serializable 같은 선택적 기능을 혼합 계층 없는 타입 프레임워크를 만들 수 있다 — 가수이면서 작곡가인 SingerSongwriter둘의 장점을 결합하려면 인터페이스 + 추상 골격 구현(skeletal implementation) 클래스를 함께 제공하라. 관례적으로 Abstract~로 이름 짓는다.public interface Vending { void start(); void process(); void stop();}public abstract class AbstractVending implements Vending { @Override public void start() { System.out.println("전원 On"); } @Override public void stop() { System.out.println("전원 Off"); } @Override public void process() { start(); operate(); stop(); } abstract void operate(); // 하위 클래스가 구현}Item 21. 인터페이스는 구현하는 쪽을 생각해 설계하라Java 8의 default 메서드는 기존 구현체를 깨뜨릴 수 있다. Collection.removeIf()는 범용적이지만, SynchronizedCollection에 대해서는 동기화를 해주지 않아 ConcurrentModificationException이 발생할 수 있다.새 인터페이스는 릴리스 전에 최소 세 가지 다른 구현을 만들어 테스트하라.Item 22. 인터페이스는 타입을 정의하는 용도로만 사용하라상수 인터페이스 안티패턴 — 메서드 없이 상수만 선언한 인터페이스 — 은 내부 구현을 클래스의 API로 노출하는 행위다.// 안티패턴: 상수 인터페이스public interface PhysicalConstants { double AVOGADROS_NUMBER = 6.022_140_857e23; // 구현체에 불필요한 상수 노출}// 올바른 방법: 유틸리티 클래스public final class PhysicalConstants { private PhysicalConstants() { } // 인스턴스화 방지 public static final double AVOGADROS_NUMBER = 6.022_140_857e23;}// 사용: import static com.example.PhysicalConstants.AVOGADROS_NUMBER;상수를 공개하려면 관련 클래스/인터페이스에 직접 추가하거나, 열거 타입으로 만들거나, 인스턴스화 불가한 유틸리티 클래스에 담아라.Item 23. 태그 달린 클래스보다는 클래스 계층구조를 활용하라태그 필드(shape 같은 enum)로 여러 의미를 표현하는 클래스는 장황하고 오류 내기 쉽다.// 나쁜 예: 태그 달린 클래스class Figure { enum Shape { RECTANGLE, CIRCLE } final Shape shape; double length, width; // RECTANGLE 전용 double radius; // CIRCLE 전용 // ...}// 좋은 예: 클래스 계층구조abstract class Figure { abstract double area(); }class Circle extends Figure { final double radius; @Override double area() { return Math.PI * radius * radius; }}class Rectangle extends Figure { final double length, width; @Override double area() { return length * width; }}Item 24. 멤버 클래스는 되도록 static으로 만들라중첩 클래스가 바깥 인스턴스에 접근할 일이 없으면 무조건 static으로 선언하라. 비정적 멤버 클래스는 바깥 인스턴스로의 숨은 외부 참조를 갖게 되어 GC가 바깥 인스턴스를 수거하지 못하는 메모리 누수가 발생할 수 있다. 종류 바깥 참조 대표 용례 정적 멤버 클래스 없음 Map.Entry 비정적 멤버 클래스 있음 Adapter (뷰 제공) 익명 클래스 있음 람다 이전의 함수 객체 지역 클래스 있음 거의 안 씀 Item 25. 톱레벨 클래스는 한 파일에 하나만 담으라한 소스 파일에 톱레벨 클래스를 여러 개 넣으면 컴파일 순서에 따라 동작이 달라질 수 있다. 정적 멤버 클래스(Item 24)로 대체하면 된다.5장. 제네릭Item 26. Raw Type은 사용하지 말라Raw Type(List)을 쓰면 제네릭의 안전성과 표현력을 모두 잃는다.// Raw Type: 런타임에 ClassCastExceptionprivate final Collection stamps = ...;stamps.add(new Coin(...)); // 경고만 발생// 제네릭: 컴파일 타임에 차단private final Collection<Stamp> stamps = ...;stamps.add(new Coin(...)); // 컴파일 에러타입을 모르거나 신경 쓰고 싶지 않을 때는 Set<?> (비한정적 와일드카드 타입)을 쓰라. Raw Type과 달리 null 외에 아무것도 넣을 수 없어 타입 안전하다.예외: class 리터럴(List.class)과 instanceof 연산자에서는 Raw Type을 쓴다. 런타임에 제네릭 정보가 소거되기 때문이다.flowchart LR A["Raw Type<br/>List"] -->|"unsafe"| B["❌ 런타임 ClassCastException"] C["Generic<br/>List&lt;String&gt;"] -->|"safe"| D["✅ 컴파일타임 오류 검출"] E["Wildcard<br/>List&lt;?&gt;"] -->|"safe"| F["✅ null 외 원소 추가 불가"]실무에서 자주 겠는 실수: 레거시 코드에서 List로 받아 어딜가에서 (String) list.get(0) 캐스팅. 이런 코드는 List<String> 또는 List<?>로 리팩터링해야 한다.Item 27. 비검사 경고를 제거하라제네릭을 쓰면 수많은 unchecked 경고가 뜬다. 할 수 있는 한 모두 제거하라. 경고를 하나도 남기지 않으면 런타임에 ClassCastException이 발생하지 않음을 보장한다.경고를 제거할 수 없지만 코드가 타입 안전하다고 확신하면 @SuppressWarnings("unchecked")를 최대한 좁은 범위에 붙이고, 그 이유를 주석으로 남겨라.Item 28. 배열보다는 리스트를 사용하라배열과 제네릭에는 중요한 차이 두 가지가 있다.flowchart TB subgraph 배열["배열 Array"] direction TB A1["공변 Covariant<br/>Sub[] ⊂ Super[]"] A2["실체화 Reified<br/>런타임에 타입 인지"] A3["❗ 런타임에 실패<br/>ArrayStoreException"] end subgraph 제네릭["제네릭 Generic"] direction TB G1["불공변 Invariant<br/>List&lt;Sub&gt; ≠ List&lt;Super&gt;"] G2["타입 소거 Erasure<br/>런타임에 타입 정보 없음"] G3["✅ 컴파일 타임에 실패<br/>컴파일 에러"] end 배열 제네릭 공변성 공변 — Sub[]은 Super[]의 하위 타입 불공변 — List<Sub> ≠ List<Super> 실체화 런타임에 타입 인지 런타임에 타입 소거 // 배열: 런타임에 실패Object[] objectArray = new Long[1];objectArray[0] = "문자열"; // ArrayStoreException// 리스트: 컴파일 타임에 실패List<Object> ol = new ArrayList<Long>(); // 컴파일 에러배열은 런타임에 타입 안전하지만 컴파일 타임에는 아니다. 제네릭은 그 반대다. 배열과 제네릭을 섞어 쓰다 경고를 받으면 배열을 리스트로 대체하라.Item 29. 이왕이면 제네릭 타입으로 만들라클라이언트에서 직접 형변환하는 코드가 있다면 제네릭으로 만들어야 한다는 신호다.Object 기반 컬렉션을 제네릭으로 전환할 때 new E[]를 쓸 수 없는 문제는 두 가지로 해결한다.// 방법 1 (선호): 배열을 E[]로 캐스팅, @SuppressWarnings@SuppressWarnings("unchecked")public Stack() { elements = (E[]) new Object[DEFAULT_SIZE]; }// 방법 2: 배열은 Object[], 꺼낼 때마다 캐스팅public E pop() { @SuppressWarnings("unchecked") E result = (E) elements[--size]; return result;}Item 30. 이왕이면 제네릭 메서드로 만들라메서드도 형변환 없이 사용할 수 있도록 제네릭으로 만들라.// Raw Type → type-unsafepublic static Set union(Set s1, Set s2) { ... }// 제네릭 메서드 → type-safepublic static <E> Set<E> union(Set<E> s1, Set<E> s2) { Set<E> result = new HashSet<>(s1); result.addAll(s2); return result;}재귀적 타입 한정(recursive type bound)은 주로 Comparable과 함께 쓴다. <E extends Comparable<E>>는 “자기 자신과 비교할 수 있는 모든 타입 E”를 뜻한다.Item 31. 한정적 와일드카드를 사용해 API 유연성을 높이라매개변수화 타입은 불공변이므로, 유연성을 위해 한정적 와일드카드가 필요하다. 핵심 공식은 PECS: Producer-Extends, Consumer-Super다.// 생산자: src에서 꺼내 push → extendspublic void pushAll(Iterable<? extends E> src) { for (E e : src) push(e);}// 소비자: pop하여 dst에 넣음 → superpublic void popAll(Collection<? super E> dst) { while (!isEmpty()) dst.add(pop());}반환 타입에는 와일드카드를 쓰지 마라 — 클라이언트 코드까지 와일드카드를 신경 써야 한다.flowchart LR subgraph PECS["PECS 원칙"] direction TB P["프로듀서 Producer"] --> PE["? extends E<br/>데이터를 꺼내 쓸 때"] C["컨슈머 Consumer"] --> CS["? super E<br/>데이터를 넣을 때"] end타입 매개변수가 한 번만 등장하면 와일드카드로 대체하라.public static void swap(List<?> list, int i, int j); // 좋음public static <E> void swap(List<E> list, int i, int j); // 불필요하게 복잡Item 32. 제네릭과 가변인수를 함께 쓸 때는 신중하라가변인수 메서드를 호출하면 배열이 만들어지는데, 이 배열이 클라이언트에게 노출되면 타입 안전성이 깨진다.@SafeVarargs는 두 조건을 만족할 때만 달라. 가변인수 배열에 아무것도 저장하지 않는다 그 배열(또는 복제본)을 신뢰할 수 없는 코드에 노출하지 않는다안전한 대안은 가변인수를 List로 대체하는 것이다.// 위험: 배열 반환static <T> T[] toArray(T... args) { return args; }// 안전: List 반환@SafeVarargsstatic <T> List<T> flatten(List<? extends T>... lists) { List<T> result = new ArrayList<>(); for (List<? extends T> list : lists) result.addAll(list); return result;}Item 33. 타입 안전 이종 컨테이너를 고려하라일반적인 Map<K, V>나 Set<E>는 타입 매개변수의 수가 고정되어 있다. 더 유연하게 쓰려면 컨테이너 대신 키를 매개변수화하라.public class Favorites { private Map<Class<?>, Object> favorites = new HashMap<>(); public <T> void putFavorite(Class<T> type, T instance) { favorites.put(Objects.requireNonNull(type), type.cast(instance)); } public <T> T getFavorite(Class<T> type) { return type.cast(favorites.get(type)); }}// 사용Favorites f = new Favorites();f.putFavorite(String.class, "Java");f.putFavorite(Integer.class, 0xcafebabe);String s = f.getFavorite(String.class); // "Java"Class<T>를 키로 쓰면 컴파일 타임과 런타임의 타입 정보를 연결할 수 있다. 이런 Class 객체를 타입 토큰(type token)이라 한다. 제약: List<String>.class 같은 실체화 불가 타입은 키로 쓸 수 없다.6장. 열거 타입과 애너테이션Item 34. int 상수 대신 열거 타입을 사용하라정수 열거 패턴(public static final int)은 타입 안전을 보장하지 못하고, 네임스페이스가 없으며, 디버깅 시 의미 없는 숫자만 보인다.열거 타입은 완전한 클래스다. 상수마다 하나의 인스턴스를 보장하고, 컴파일타임 타입 안전성을 제공한다.public enum Operation { PLUS("+") { public double apply(double x, double y) { return x + y; } }, MINUS("-") { public double apply(double x, double y) { return x - y; } }, TIMES("*") { public double apply(double x, double y) { return x * y; } }, DIVIDE("/") { public double apply(double x, double y) { return x / y; } }; private final String symbol; Operation(String symbol) { this.symbol = symbol; } @Override public String toString() { return symbol; } public abstract double apply(double x, double y);}분기별 로직이 필요하면 switch 대신 상수별 메서드 구현(constant-specific method implementation)을 써라. 여러 상수에 같은 동작을 공유해야 하면 전략 열거 타입 패턴을 쓰라.// 전략 열거 타입 패턴 — 급여 유형별 잔업 수당 계산enum PayrollDay { MONDAY(PayType.WEEKDAY), TUESDAY(PayType.WEEKDAY), SATURDAY(PayType.WEEKEND), SUNDAY(PayType.WEEKEND); private final PayType payType; PayrollDay(PayType payType) { this.payType = payType; } int pay(int minsWorked, int payRate) { return payType.pay(minsWorked, payRate); } // 전략 열거 타입 enum PayType { WEEKDAY { int overtimePay(int mins, int rate) { return mins <= 480 ? 0 : (mins - 480) * rate / 2; } }, WEEKEND { int overtimePay(int mins, int rate) { return mins * rate / 2; } }; abstract int overtimePay(int mins, int rate); int pay(int minsWorked, int payRate) { int basePay = minsWorked * payRate; return basePay + overtimePay(minsWorked, payRate); } }}enum에 fromString 팩터리 메서드를 제공하면 문자열에서 enum으로 변환하기 편리하다.private static final Map<String, Operation> stringToEnum = Stream.of(values()).collect(toMap(Object::toString, e -> e));public static Optional<Operation> fromString(String symbol) { return Optional.ofNullable(stringToEnum.get(symbol));}Item 35. ordinal 메서드 대신 인스턴스 필드를 사용하라ordinal()은 상수 선언 순서에 의존하므로 상수를 추가하거나 순서를 바꾸면 깨진다.// 나쁜 예public enum Ensemble { SOLO, DUET, TRIO; // ordinal이 0, 1, 2 public int numberOfMusicians() { return ordinal() + 1; }}// 좋은 예public enum Ensemble { SOLO(1), DUET(2), TRIO(3), OCTET(8); private final int numberOfMusicians; Ensemble(int size) { this.numberOfMusicians = size; }}JPA에서 @Enumerated(EnumType.ORDINAL) 역시 같은 이유로 위험하다. EnumType.STRING을 쓰라.enum 상수에 연관된 숫자 값이 필요하면 인스턴스 필드로 저장하라. ordinal()의 용도는 EnumSet, EnumMap 같은 범용 데이터 구조에 쓰기 위한 것이지, 프로그래머가 직접 쓰라고 있는 것이 아니다.Item 36. 비트 필드 대신 EnumSet을 사용하라EnumSet은 내부적으로 비트 벡터로 구현되어 비트 필드에 필적하는 성능을 내면서, Set 인터페이스를 완벽히 구현한다.// 비트 필드 (나쁜 예)public static final int STYLE_BOLD = 1 << 0; // 1public static final int STYLE_ITALIC = 1 << 1; // 2text.applyStyles(STYLE_BOLD | STYLE_ITALIC); // 3 — 의미 파악 어려움// EnumSet (좋은 예)public enum Style { BOLD, ITALIC, UNDERLINE, STRIKETHROUGH }text.applyStyles(EnumSet.of(Style.BOLD, Style.ITALIC)); // 타입 안전, 명확Item 37. ordinal 인덱싱 대신 EnumMap을 사용하라ordinal()로 배열 인덱스를 매기면 타입 안전하지 않고, 출력 시 직접 레이블을 달아야 한다. EnumMap은 내부에서 배열을 쓰면서도 Map 인터페이스를 제공한다.// EnumMap 사용Map<Plant.LifeCycle, Set<Plant>> plantsByLifeCycle = new EnumMap<>(Plant.LifeCycle.class);for (Plant.LifeCycle lc : Plant.LifeCycle.values()) plantsByLifeCycle.put(lc, new HashSet<>());// Stream + EnumMapArrays.stream(garden) .collect(groupingBy(p -> p.lifeCycle, () -> new EnumMap<>(LifeCycle.class), toSet()));Item 38. 확장할 수 있는 열거 타입이 필요하면 인터페이스를 사용하라열거 타입은 확장할 수 없지만, 인터페이스를 구현하여 같은 효과를 낼 수 있다.public interface Operation { double apply(double x, double y);}public enum BasicOperation implements Operation { PLUS("+") { public double apply(double x, double y) { return x + y; } }, MINUS("-") { public double apply(double x, double y) { return x - y; } }; // ...}// 확장public enum ExtendedOperation implements Operation { EXP("^") { public double apply(double x, double y) { return Math.pow(x, y); } }, REMAINDER("%") { public double apply(double x, double y) { return x % y; } }; // ...}Item 39. 명명 패턴보다 애너테이션을 사용하라JUnit 3의 test로 시작하는 명명 패턴은 오타에 취약하고, 매개변수를 전달할 방법이 없다. 애너테이션(@Test)이 이 모든 문제를 해결한다.애너테이션은 이 타입의 프로그램 요소에 아무런 영향을 주지 않고 단지 관심 있는 도구(테스트 프레임워크 등)에 정보를 제공할 뿐이다.Item 40. @Override 애너테이션을 일관되게 사용하라@Override를 빠뜨리면 재정의(overriding)가 아니라 다중정의(overloading)가 되는 미묘한 버그가 발생한다.// 버그: Object.equals(Object)를 재정의한 게 아니라 다중정의public boolean equals(Bigram other) { ... } // 매개변수 타입이 Object가 아님// @Override를 붙이면 컴파일러가 잡아줌@Overridepublic boolean equals(Object o) { ... } // 올바른 시그니처상위 클래스의 메서드를 재정의하는 모든 메서드에 @Override를 달라. 구체 클래스에서 추상 메서드를 구현할 때도 붙이는 습관을 들이면 안전하다.Item 41. 정의하려는 것이 타입이라면 마커 인터페이스를 사용하라 비교 마커 인터페이스 마커 애너테이션 타입으로 사용 O (컴파일타임 오류 검출) X (런타임에야 검출) 적용 대상 정밀도 특정 인터페이스의 하위 타입에 제한 가능 @Target(TYPE) 수준 클래스 외 프로그램 요소에 적용 X O 마킹된 객체를 매개변수로 받는 메서드를 작성할 일이 있으면 → 마커 인터페이스. 프레임워크에서 애너테이션을 적극 활용하고 있으면 → 마커 애너테이션.7장. 람다와 스트림Item 42. 익명 클래스보다는 람다를 사용하라Java 8부터 추상 메서드 하나인 인터페이스(함수형 인터페이스)는 람다로 대체할 수 있다.// 익명 클래스 — 장황Collections.sort(words, new Comparator<String>() { public int compare(String s1, String s2) { return Integer.compare(s1.length(), s2.length()); }});// 람다 — 간결Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));// 메서드 참조 — 더 간결words.sort(comparingInt(String::length));람다에서 this는 바깥 인스턴스를 가리킨다. 자기 자신을 참조해야 하면 익명 클래스를 써야 한다. 람다는 직렬화하지 마라.람다 사용 시 주의점: 람다는 이름이 없고 문서화도 못한다. 3줄이 넘거나, 읽기 어려워지면 람다를 쓰지 마라. 람다 안의 변수는 사실상 final이다. 바깥 지역 변수를 수정해야 하면 람다 대신 일반 반복문을 쓰라.Item 43. 람다보다는 메서드 참조를 사용하라메서드 참조가 람다보다 간결하면 메서드 참조를 쓰고, 아니면 람다를 쓰라. 유형 메서드 참조 동등한 람다 정적 Integer::parseInt str -> Integer.parseInt(str) 한정적 인스턴스 inst::isAfter t -> inst.isAfter(t) 비한정적 인스턴스 String::toLowerCase str -> str.toLowerCase() 클래스 생성자 TreeMap<K,V>::new () -> new TreeMap<>() 배열 생성자 int[]::new len -> new int[len] Item 44. 표준 함수형 인터페이스를 사용하라java.util.function에 43개 인터페이스가 있지만, 기본 6개만 기억하면 나머지는 유추할 수 있다. 인터페이스 시그니처 예시 UnaryOperator<T> T → T String::toLowerCase BinaryOperator<T> (T, T) → T BigInteger::add Predicate<T> T → boolean Collection::isEmpty Function<T, R> T → R Arrays::asList Supplier<T> () → T Instant::now Consumer<T> T → void System.out::println 기본 타입 전용 변형(IntFunction, LongToDoubleFunction 등)이 있으니 박싱 타입을 넣어 사용하지 마라.직접 만든 함수형 인터페이스에는 반드시 @FunctionalInterface를 달라.Item 45. 스트림은 주의해서 사용하라스트림 파이프라인은 지연 평가(lazy evaluation)된다. 종단 연산이 없으면 아무 일도 하지 않는다.스트림을 과용하면 읽기 어렵고 유지보수가 힘들다. 반복문이 더 알맞는 경우도 있다.flowchart TB subgraph 스트림["✅ 스트림이 적합한 경우"] S1["원소 변환 (map)"] S2["원소 필터링 (filter)"] S3["원소 결합 (reduce, collect)"] S4["컬렉션으로 모으기 (toList)"] S5["특정 조건 원소 찾기 (findFirst)"] end subgraph 반복문["✅ 반복문이 적합한 경우"] L1["지역 변수 읽기/수정"] L2["return / break / continue"] L3["checked exception 던지기"] L4["여러 단계의 값에 동시 접근"] end확신이 없으면 둘 다 해보고 더 나은 쪽을 택하라. 스트림 파이프라인은 되풀이되는 계산을 일급 함수 객체로 표현하고, 반복문은 코드 블록으로 표현한다. 코드 블록에서는 범위 안의 지역 변수를 읽고 수정할 수 있지만, 람다에서는 사실상 final인 변수만 읽을 수 있다.Item 46. 스트림에서는 부작용 없는 함수를 사용하라스트림 패러다임의 핵심은 각 변환 단계가 순수 함수(pure function)여야 한다는 것이다.// 나쁜 예: forEach에서 외부 상태 수정Map<String, Long> freq = new HashMap<>();words.forEach(word -> freq.merge(word, 1L, Long::sum)); // 스트림 API의 형태를 빌린 반복문// 좋은 예: Collector 사용Map<String, Long> freq = words.collect(groupingBy(String::toLowerCase, counting()));forEach는 스트림 계산 결과를 보고할 때만 쓰고, 계산 자체에는 쓰지 마라.핵심 Collector 활용법:// 리스트로 모으기List<String> topTen = freq.keySet().stream() .sorted(comparing(freq::get).reversed()) .limit(10) .collect(toList());// Map으로 모으기 (충돌 시 마지막 값 유지)Map<Artist, Album> topHits = albums.collect( toMap(Album::artist, a -> a, maxBy(comparing(Album::sales))));// 분류후 다운스트림 Map<Boolean, List<Integer>> partition = numbers.stream() .collect(partitioningBy(n -> n % 2 == 0));핵심 Collector: toList(), toSet(), toMap(), groupingBy(), joining().Item 47. 반환 타입으로는 스트림보다 컬렉션이 낫다Stream은 Iterable을 확장하지 않아 for-each로 순회할 수 없다. 원소 시퀀스를 반환하는 공개 API의 반환 타입으로는 Collection이나 그 하위 타입을 쓰는 게 최선이다. 반복과 스트림 양쪽을 모두 지원할 수 있기 때문이다.컬렉션이 너무 크면 전용 컬렉션(AbstractList 등)을 구현하여 메모리를 아끼자.Item 48. 스트림 병렬화는 주의해서 적용하라parallel()을 호출하면 무조건 빨라지는 게 아니다. 잘못 적용하면 성능 저하, 오작동, 응답 불가가 생긴다.병렬화 효과가 좋은 조건: 소스가 ArrayList, HashMap, int[], long[] 등 쪼개기 좋은 자료구조 종단 연산이 축소(reduce, min, max, count, sum) 또는 단락 평가(anyMatch, allMatch) 참조 지역성(locality of reference)이 뛰어남Stream.iterate나 limit이 포함된 파이프라인은 병렬화해도 나아지지 않는다.병렬 스트림에서 난수가 필요하면 SplittableRandom을 우선 검토하라. 병렬 분할에 맞춰 설계되어 있어 공유형 난수 생성기보다 다루기 쉽다.8장. 메서드Item 49. 매개변수가 유효한지 검사하라메서드 바디 실행 전에 매개변수를 검증하라. 오류는 가능한 빨리, 발생한 곳에서 잡아야 한다.// public 메서드: @throws로 문서화/** * @throws ArithmeticException m이 0 이하일 때 */public BigInteger mod(BigInteger m) { if (m.signum() <= 0) throw new ArithmeticException("modulus must be positive: " + m); // ...}// private 메서드: assert로 검증private static void sort(long[] a, int offset, int length) { assert a != null; assert offset >= 0 && offset <= a.length; // ...}Java 7+에서는 Objects.requireNonNull()을, Java 9+에서는 checkFromIndexSize() 등을 활용하라.Item 50. 적시에 방어적 복사본을 만들라클라이언트가 불변식을 깨뜨리려 한다고 가정하고 방어적으로 프로그래밍하라.public Period(Date start, Date end) { this.start = new Date(start.getTime()); // 방어적 복사 먼저 this.end = new Date(end.getTime()); if (this.start.compareTo(this.end) > 0) // 유효성 검사 나중에 (TOCTOU 방지) throw new IllegalArgumentException(start + " > " + end);}public Date getStart() { return new Date(start.getTime()); // 접근자에서도 방어적 복사}복사에 clone()을 쓰면 안 된다 — 매개변수 타입이 확장 가능하면 하위 클래스가 clone을 악의적으로 재정의할 수 있다. Date 대신 불변인 Instant, LocalDateTime을 쓰면 방어적 복사 자체가 불필요하다.Item 51. 메서드 시그니처를 신중히 설계하라다섯 가지 지침: 메서드 이름을 신중히 짓고 표준 명명 규칙을 따르라 편의 메서드를 너무 많이 만들지 마라 매개변수 목록은 4개 이하로 유지하라 — 메서드 쪼개기, 도우미 클래스, 빌더 패턴 활용 매개변수 타입은 클래스보다 인터페이스가 낫다 (HashMap → Map) boolean보다 원소 2개짜리 열거 타입이 낫다 (TemperatureScale.CELSIUS vs true)Item 52. 다중정의(overloading)는 신중히 사용하라다중정의된 메서드 중 어느 것이 호출될지는 컴파일 시점에 결정된다. 재정의(overriding)는 런타임에 결정된다. 이 차이가 혼란을 만든다.// List.remove의 함정List<Integer> list = new ArrayList<>(Arrays.asList(-3, -2, -1, 0, 1, 2));for (int i = 0; i < 3; i++) list.remove(i);// 결과: [-2, 0, 2] (인덱스 기반 삭제)// 의도: list.remove((Integer) i) (값 기반 삭제)안전한 규칙: 같은 수의 매개변수를 갖는 다중정의를 두 개 이상 만들지 마라. 가변인수 메서드는 아예 다중정의하지 마라. 이름을 다르게 지어라 (writeInt, writeLong처럼).특히 ObjectOutputStream은 write와 writeObject 두 가지 메서드를 모두 제공하지만, 다중정의 없이 이름 자체로 구분한 좋은 예다.Java 5에서 오토박싱이 도입되면서 List.remove(int) vs List.remove(Object)의 혼란이 생겼다. List<Integer>에서 remove(3)은 인덱스 3의 원소를 제거하는지, 값 3을 제거하는지 모호해진다.Item 53. 가변인수는 신중히 사용하라가변인수 호출마다 배열이 새로 할당된다. 성능에 민감하면 자주 쓰는 인수 개수별로 오버로딩하라.// 인수가 최소 1개 필요하면: 첫 번째를 분리static int min(int firstArg, int... remainingArgs) { int min = firstArg; for (int arg : remainingArgs) if (arg < min) min = arg; return min;}EnumSet.of(E first, E... rest)가 이 패턴의 좋은 예다.Item 54. null이 아닌, 빈 컬렉션이나 배열을 반환하라null을 반환하면 클라이언트에 null 검사 코드가 강제된다. 빈 컬렉션이나 빈 배열을 반환하라. 할당이 걱정되면 Collections.emptyList() 같은 불변 빈 컬렉션을 재사용하라.비어있는지 확인하지 않고 바로 반환하는 것도 가능하다:// 더 간결한 방식 — 어떨 때든 새 리스트 생성public List<Cheese> getCheeses() { return new ArrayList<>(cheesesInStock);}// 배열 버전: 빈 배열 상수 재사용private static final Cheese[] EMPTY_CHEESE_ARRAY = new Cheese[0];public Cheese[] getCheeses() { return cheesesInStock.toArray(EMPTY_CHEESE_ARRAY);}public List<Cheese> getCheeses() { return cheesesInStock.isEmpty() ? Collections.emptyList() : new ArrayList<>(cheesesInStock);}Item 55. Optional 반환은 신중히 하라반환값이 없을 수 있음을 API에서 명시하는 것이 Optional의 존재 이유다.public static <E extends Comparable<E>> Optional<E> max(Collection<E> c) { if (c.isEmpty()) return Optional.empty(); // ... return Optional.of(result);}// 사용String lastWord = max(words).orElse("단어 없음");String lastWord = max(words).orElseThrow(NoSuchElementException::new); 컨테이너 타입(Collection, Stream, 배열)을 Optional로 감싸지 마라 — 빈 컬렉션을 반환하라 기본 타입에는 OptionalInt, OptionalLong, OptionalDouble을 쓰라 Optional을 Map의 값으로 쓰지 마라 orElse()는 값이 있어도 기본값을 생성하므로 비싼 연산이면 orElseGet()을 써라Item 56. 공개된 API 요소에는 항상 문서화 주석을 작성하라공개된 모든 클래스, 인터페이스, 메서드, 필드 선언에 문서화 주석을 달라. 메서드는 무엇을 하는지(what) 기술하고, 어떻게(how) 동작하는지는 기술하지 않는다.모든 매개변수에 @param, 반환값에 @return, 발생 가능한 예외에 @throws를 달라. 스레드 안전성과 직렬화 가능 여부도 문서화하라.9장. 일반적인 프로그래밍 원칙Item 57. 지역변수의 범위를 최소화하라가장 처음 쓰일 때 선언하고, 선언과 동시에 초기화하라. for 문이 while 문보다 낫다 — 반복 변수의 유효 범위가 for 블록 안으로 제한되어 복사/붙여넣기 버그를 컴파일러가 잡아준다.// while: i가 여전히 유효 → 복사/붙여넣기 시 버그 가능Iterator<Integer> i = c.iterator();while (i.hasNext()) { doSomething(i.next()); }// i는 여전히 접근 가능// for: i의 범위가 for 블록 안으로 제한for (Iterator<Integer> i = c.iterator(); i.hasNext(); ) { doSomething(i.next());}// i에 접근하면 컴파일 에러Item 58. 전통적인 for 문보다는 for-each 문을 사용하라for-each는 반복자(iterator)와 인덱스 변수를 숨겨 코드를 깔끔하게 만들고 오류를 줄인다.for-each를 쓸 수 없는 세 가지 상황: 파괴적 필터링 — 순회 중 원소 제거 시 (Collection.removeIf() 또는 iterator.remove()) 변형 — 원소 값 교체 시 인덱스 필요 병렬 반복 — 여러 컬렉션을 동시에 순회Item 59. 라이브러리를 익히고 사용하라바퀴를 재발명하지 마라. java.lang, java.util, java.io와 하위 패키지는 반드시 숙지하라.랜덤 수 생성: Random.nextInt(n) → Java 7+ ThreadLocalRandom → 병렬: SplittableRandom.Item 60. 정확한 답이 필요하다면 float와 double은 피하라float/double은 이진 부동소수점 연산을 위해 설계되었다. 금융 계산처럼 정확한 결과가 필요하면 BigDecimal, int, long을 쓰라.// 부정확1.03 - 0.42 // → 0.6100000000000001// BigDecimal로 해결new BigDecimal("1.03").subtract(new BigDecimal("0.42")) // → 0.61소수점 이하 자릿수가 정해져 있으면 정수로 환산하여 계산하는 것이 가장 빠르다 (예: 달러 → 센트). 방법 성능 편의성 소수점 자릿수 int/long 최고 낮음 직접 관리 BigDecimal 낮음 높음 자동 관리 9자리 이하면 int, 18자리 이하면 long, 그 이상이면 BigDecimal을 쓰라.BigDecimal 사용 시 new BigDecimal(0.1)은 정확히 0.1이 아니다. new BigDecimal("0.1") 또는 BigDecimal.valueOf(0.1)을 쓰라.Item 61. 박싱된 기본 타입보다는 기본 타입을 사용하라flowchart TB subgraph 차이["기본 타입 vs 박싱 타입"] direction TB D1["1️⃣ 식별성: 박싱 타입은 identity가 있다<br/>→ == 비교 시 참조 비교 (값이 같아도 false)"] D2["2️⃣ Null: 박싱 타입은 null 가능<br/>→ 언박싱 시 NullPointerException"] D3["3️⃣ 성능: 기본 타입이 시간·메모리 모두 효율적<br/>→ 불필요한 오토박싱은 성능 저하"] end// 함정: new Integer(42) == new Integer(42) → falseComparator<Integer> naturalOrder = (i, j) -> (i < j) ? -1 : (i == j ? 0 : 1);// i == j는 참조 비교! → 같은 값이어도 1을 반환// 해결: 언박싱 후 비교Comparator<Integer> naturalOrder = (iBoxed, jBoxed) -> { int i = iBoxed, j = jBoxed; return (i < j) ? -1 : (i == j ? 0 : 1);};박싱 타입을 써야 하는 곳: 컬렉션의 원소/키/값, 제네릭 타입 매개변수, 리플렉션.Item 62. 다른 타입이 적절하다면 문자열 사용을 피하라문자열은 값 타입, 열거 타입, 혼합 타입, 권한(capability)을 대신하기에 적합하지 않다. 수치는 int/BigInteger, 예/아니오는 boolean/enum을 써라.실무에서 자주 보는 안티패턴:// 나쁜 예: 타입 정보를 문자열로 전달String compoundKey = className + "#" + fieldName + "/" + typeName;// → 파싱 오류, equals 구현 복잡, 성능 나쁨// 좋은 예: 전용 클래스 사용record FieldKey(String className, String fieldName, String typeName) { }Item 63. 문자열 연결은 느리니 주의하라반복문 안에서 + 연산자를 반복 사용해 문자열을 계속 연결하면, 매번 새로운 문자열을 생성하고 복사하므로 시간 복잡도가 $O(n^2)$이 된다. String이 불변이라 매번 양쪽 내용을 복사하기 때문이다. 많은 문자열을 결합할 때는 StringBuilder를 쓰라.// 느림: O(n²)String result = "";for (int i = 0; i < n; i++) result += lineForItem(i);// 빠름: O(n)StringBuilder sb = new StringBuilder();for (int i = 0; i < n; i++) sb.append(lineForItem(i));return sb.toString();Item 64. 객체는 인터페이스를 사용해 참조하라적합한 인터페이스가 있으면 매개변수, 반환값, 변수, 필드를 전부 인터페이스 타입으로 선언하라. 구현 타입을 바꿀 때 선언부만 수정하면 된다.Set<Son> sonSet = new LinkedHashSet<>(); // 좋음LinkedHashSet<Son> sonSet = new LinkedHashSet<>(); // 나쁨 — 구현에 종속적합한 인터페이스가 없다면 클래스 계층에서 가장 상위의 적합한 클래스 타입을 사용하라.Item 65. 리플렉션보다는 인터페이스를 사용하라리플렉션의 단점 세 가지: 컴파일타임 검사 불가, 코드가 장황하고 읽기 어려움, 성능 저하.리플렉션은 인스턴스 생성에만 쓰고, 만든 인스턴스는 인터페이스나 상위 클래스로 참조하라.Class<? extends Set<String>> cl = (Class<? extends Set<String>>) Class.forName(args[0]);Set<String> s = cl.getDeclaredConstructor().newInstance(); // 리플렉션은 여기까지만s.addAll(Arrays.asList(args).subList(1, args.length)); // 이후는 인터페이스로Item 66. 네이티브 메서드는 신중히 사용하라네이티브 메서드는 C, C++ 같은 언어로 작성한 코드를 JNI 등을 통해 자바에서 호출하는 방식이다. 운영체제 기능에 직접 접근하거나, 이미 존재하는 네이티브 라이브러리를 재사용해야 할 때는 유용하지만 일반적인 애플리케이션 로직의 기본 수단으로 삼기에는 비용이 크다.네이티브 메서드의 단점은 분명하다. 이식성이 떨어진다. 운영체제와 CPU 아키텍처가 바뀌면 다시 빌드하고 검증해야 한다. 자바의 안전 장치를 벗어난다. 포인터 오류, 메모리 누수, 잘못된 자원 관리는 JVM 전체 안정성을 해칠 수 있다. 디버깅과 배포가 까다롭다. 크래시가 나면 자바 스택만으로 원인을 좁히기 어렵고, 라이브러리 로딩 문제도 자주 생긴다. 성능 이점이 항상 크지 않다. JNI 경계를 넘는 비용과 데이터 변환 비용 때문에 작은 작업은 오히려 손해일 수 있다.public final class NativeMath { static { System.loadLibrary("native-math"); } private NativeMath() { } public static native long fastHash(byte[] input);}실무에서는 “반드시 운영체제 고유 기능이 필요한가”, “이미 검증된 네이티브 라이브러리를 재사용해야 하는가”를 먼저 따져라. 그렇지 않다면 순수 자바 구현이 보통 더 안전하고 유지보수하기 쉽다.Item 67. 최적화는 신중히 하라 “섣부른 최적화는 만악의 근원이다” — Donald Knuth빠른 프로그램이 아닌 좋은 프로그램을 작성하라. 설계가 좋으면 추후 성능 문제가 발생했을 때 전체 아키텍처를 훼손하지 않고 효과적으로 최적화할 수 있다. 성능을 제한하는 설계 결정(API, wire-level 프로토콜, 데이터 포맷)에만 신경 쓰라. 최적화 전후에 반드시 프로파일러로 성능을 측정하라.Item 68. 일반적으로 통용되는 명명 규칙을 따르라 대상 규칙 예시 패키지 소문자, 도메인 역순 com.google.common.collect 클래스/인터페이스 PascalCase HttpUrl, FutureTask 메서드/필드 camelCase ensureCapacity 상수 UPPER_SNAKE_CASE MIN_VALUE 타입 매개변수 한 글자 T, E, K, V, X boolean 반환 메서드는 is, has로 시작하고, 타입 변환 메서드는 to~, 뷰 반환은 as~, 기본 타입 반환은 ~Value 형태를 쓴다.10장. 예외Item 69. 예외는 진짜 예외 상황에만 사용하라예외를 일상적 제어 흐름에 쓰면 안 된다. try-catch 블록은 JVM 최적화를 제한하므로 오히려 느려진다.잘 설계된 API는 정상 제어 흐름에서 예외를 쓸 일이 없도록 상태 검사 메서드를 제공한다 (예: Iterator.hasNext() + next()).상태 검사 메서드 vs Optional vs 구별값(예: null) 선택 기준: 상황 추천 외부 동기화 없이 호출 가능 상태 검사 메서드 동시성 문제 가능성 Optional 또는 구별값 성능 중요 구별값 Item 70. 복구 가능하면 checked exception, 프로그래밍 오류면 runtime exceptionclassDiagram class Throwable { <<abstract>> } class Error { ⛔ JVM 자원 부족 등 잡지 마라 } class Exception { } class RuntimeException { 🐛 프로그래밍 오류 (unchecked) } class IOException { 🔄 복구 가능 상황 (checked) } class NullPointerException class IllegalArgumentException class IndexOutOfBoundsException Throwable <|-- Error Throwable <|-- Exception Exception <|-- RuntimeException Exception <|-- IOException RuntimeException <|-- NullPointerException RuntimeException <|-- IllegalArgumentException RuntimeException <|-- IndexOutOfBoundsException호출자가 복구할 수 있는 상황이면 checked exception을 던지고, 복구에 필요한 정보를 제공하는 접근자 메서드를 함께 제공하라. 프로그래밍 오류(전제조건 위반)에는 runtime exception을 써라.// checked exception에 복구 정보 제공public class InsufficientFundsException extends Exception { private final long shortfall; // 부족액 public InsufficientFundsException(long shortfall) { super("\uc794\uc561 \ubd80\uc871: " + shortfall + "\uc6d0"); this.shortfall = shortfall; } public long getShortfall() { return shortfall; } // 복구에 필요한 정보}원칙: Error를 상속하지 마라. throw하지도 마라 (AssertionError 제외). Throwable을 직접 상속하지도 마라.Item 71. 필요 없는 checked exception 사용은 피하라checked exception은 API 사용성을 떨어트린다 (try-catch 강제, Stream에서 사용 불가). 복구 방법이 없다면 unchecked exception을 던지라. Optional이나 상태 검사 메서드(메서드 분할) 방식을 먼저 고려하라.Item 72. 표준 예외를 사용하라 예외 사용처 IllegalArgumentException 허용하지 않는 인수값 IllegalStateException 객체 상태가 메서드 수행에 부적합 NullPointerException null 비허용 매개변수에 null IndexOutOfBoundsException 인덱스 범위 초과 ConcurrentModificationException 허용하지 않는 동시 수정 UnsupportedOperationException 지원하지 않는 동작 표준 예외를 재사용하면 API가 익히기 쉽고, 예외 클래스가 적어 메모리 사용과 클래스 적재 시간이 줄어든다.Item 73. 추상화 수준에 맞는 예외를 던져라저수준 예외를 상위 계층에 전파하면 이상한 예외를 보게 된다. 예외 번역(exception translation)으로 상위 계층에 맞는 예외로 바꿔 던지고, 원인(cause)이 필요하면 예외 연쇄(exception chaining)를 쓰라.// 예외 번역try { return listIterator(index).next();} catch (NoSuchElementException e) { throw new IndexOutOfBoundsException("Index: " + index);}// 예외 연쇄try { // 저수준 추상화 호출} catch (LowerLevelException cause) { throw new HigherLevelException(cause);}Item 74. 메서드가 던지는 모든 예외를 문서화하라checked exception은 @throws로 하나하나 문서화하고 메서드 선언의 throws 절에 명시하라. unchecked exception도 @throws로 문서화하되, throws 절에는 넣지 마라 — checked/unchecked를 시각적으로 구분할 수 있게 된다.Item 75. 예외의 상세 메시지에 실패 관련 정보를 담으라실패 순간의 매개변수와 필드 값을 예외 메시지에 담아야 원인을 파악할 수 있다.// 나쁜 예: 원인 파악 불가throw new IndexOutOfBoundsException();// 좋은 예: 즉시 원인 파악throw new IndexOutOfBoundsException( "lowerBound=" + lowerBound + ", upperBound=" + upperBound + ", index=" + index);Item 76. 가능한 한 실패 원자적으로 만들라실패 원자성(failure atomicity): 메서드가 실패해도 객체는 호출 전 상태를 유지해야 한다.달성 방법: 불변 객체로 만들기 (가장 간단) 상태 변경 전에 매개변수 유효성 검사 임시 복사본에서 작업 후 성공 시 원본과 교체 실패 시 롤백 코드 작성Item 77. 예외를 무시하지 말라빈 catch 블록은 예외를 삼키는 행위다. 예외를 무시해야 한다면 변수 이름을 ignored로 짓고 주석으로 이유를 남겨라.try { numColors = f.get(1L, TimeUnit.SECONDS);} catch (TimeoutException | ExecutionException ignored) { // 기본 색상을 쓰면 되므로 무시해도 안전하다}11장. 동시성Item 78. 공유 중인 가변 데이터는 동기화해 사용하라synchronized는 배타적 실행뿐 아니라 스레드 간 통신(가시성)도 보장한다. 읽기와 쓰기 모두 동기화해야 제대로 동작한다.// volatile: 가장 최근에 쓴 값을 읽음 보장 (단순 읽기/쓰기 전용)private static volatile boolean stopRequested;// AtomicLong: 락 없이 thread-safe한 프로그래밍private static final AtomicLong nextSerialNumber = new AtomicLong();public static long generateSerialNumber() { return nextSerialNumber.getAndIncrement();}가장 좋은 방법은 가변 데이터를 공유하지 않는 것이다. 가변 데이터는 단일 스레드에서만 쓰라.flowchart TD Q{"가변 데이터 공유 필요?"} Q -- "아니오" --> N["해결! 불변 데이터만 공유"] Q -- "네" --> T{"단순 읽기/쓰기?"} T -- "네" --> V["volatile 사용"] T -- "아니오 (atomic 연산 필요)" --> A["Atomic 클래스 사용<br/>AtomicLong, AtomicReference"] T -- "복합 연산" --> S["synchronized / Lock"]동기화의 두 가지 기능을 항상 기억하라: (1) 배타적 실행 (2) 스레드 간 통신(가시성). 둘 다 충족되지 않으면 동기화가 제대로 되지 않는다.Item 79. 과도한 동기화는 피하라동기화 블록 안에서 외계인 메서드(재정의 가능 메서드, 클라이언트 함수 객체)를 호출하지 마라. 교착 상태나 데이터 훼손을 유발한다.해법: 외계인 메서드 호출을 동기화 블록 바깥으로 옮기거나(열린 호출, open call), CopyOnWriteArrayList를 쓰라.// CopyOnWriteArrayList: 순회가 수정보다 압도적으로 많을 때 최적private final List<SetObserver<E>> observers = new CopyOnWriteArrayList<>();public void addObserver(SetObserver<E> observer) { observers.add(observer); // 동기화 불필요!}private void notifyElementAdded(E element) { for (SetObserver<E> observer : observers) // 안전한 순회, 동기화 불필요 observer.added(this, element);}동기화의 비용은 락 획득이 아니라 경쟁(contention) 때문에 발생한다. 과도한 동기화는 병렬성을 잃고 모든 코어가 최신 메모리를 보도록 강제하며 JVM의 코드 최적화를 제한한다.Item 80. 스레드보다는 실행자, 태스크, 스트림을 애용하라Executor 프레임워크는 작업의 단위(태스크)와 실행 메커니즘을 분리한다.ExecutorService exec = Executors.newSingleThreadExecutor();exec.execute(runnable); // 태스크 제출exec.shutdown(); // 종료// 병렬 작업: ForkJoinPool + parallel stream소규모 서버: newCachedThreadPool. 대규모 프로덕션: newFixedThreadPool 또는 ThreadPoolExecutor 직접 구성.Item 81. wait와 notify보다는 동시성 유틸리티를 애용하라java.util.concurrent의 동시성 컬렉션(ConcurrentHashMap, BlockingQueue)과 동기화 장치(CountDownLatch, Semaphore)가 wait/notify보다 쉽고 안전하다.ConcurrentHashMap은 경쟁이 있는 환경에서 Collections.synchronizedMap보다 더 잘 확장되는 경우가 많다. 동시성 컬렉션에 외부 락을 덧씌우면 장점을 스스로 지워버릴 수 있다.wait를 써야 한다면 반드시 대기 반복문(wait loop) 안에서 호출하라. notify 대신 notifyAll을 써라.Item 82. 스레드 안전성 수준을 문서화하라synchronized 키워드만으로는 스레드 안전 여부를 알 수 없다. 다음 수준을 명확히 문서화하라. 수준 설명 예시 불변 외부 동기화 불필요 String, Long 무조건적 thread-safe 내부 동기화 완비 AtomicLong, ConcurrentHashMap 조건부 thread-safe 일부 메서드에 외부 동기화 필요 Collections.synchronized 래퍼 thread-safe 아님 외부 동기화 필수 ArrayList, HashMap Item 83. 지연 초기화는 신중히 사용하라대부분의 경우 일반 초기화가 지연 초기화보다 낫다. 지연 초기화가 필요한 경우에는 용도에 맞는 관용구를 써라.flowchart TD Q{"지연 초기화가 필요한가?"} Q -- "아니오" --> N["일반 초기화 사용"] Q -- "네" --> T{"정적 필드? 인스턴스 필드?"} T -- "정적 필드" --> H["홀더 클래스 관용구<br/>(lazy class loading)"] T -- "인스턴스 필드" --> D["이중검사 관용구<br/>(double-check idiom)"]// 정적 필드: 홀더 클래스 관용구 (가장 깔끔)private static class FieldHolder { static final FieldType field = computeFieldValue();}private static FieldType getField() { return FieldHolder.field; }// 인스턴스 필드: 이중검사 관용구private volatile FieldType field;private FieldType getField() { FieldType result = field; if (result != null) return result; // 첫 번째 검사 (락 없이) synchronized (this) { if (field == null) // 두 번째 검사 (락 안에서) field = computeFieldValue(); return field; }}Item 84. 프로그램의 동작을 스레드 스케줄러에 기대지 말라정확성이나 성능이 스레드 스케줄러에 의존하면 이식성이 나빠진다. 실행 가능한 스레드 수를 적게 유지하고, 바쁜 대기(busy waiting)를 피하라. Thread.yield()와 스레드 우선순위에 의존하지 마라.12장. 직렬화Item 85. 자바 직렬화의 대안을 찾으라자바 직렬화는 공격 범위가 넓고, 역직렬화 폭탄으로 서비스 거부 공격이 가능하다. 최선의 방법은 아무것도 역직렬화하지 않는 것이다.크로스 플랫폼 데이터 표현으로 JSON(텍스트, 사람이 읽기 쉬움)이나 Protocol Buffers(이진, 고효율, 스키마 제공)를 쓰라.flowchart TD Q{"데이터 직렬화 방식 선택"} Q -- "사람이 읽어야 함" --> JSON["JSON"] Q -- "성능 중요, 스키마 필요" --> PB["Protocol Buffers"] Q -- "레거시 Java" --> JD["Java 직렬화<br/>+ ObjectInputFilter"] JD --> W["⚠️ 화이트리스트 방식 사용"]레거시 시스템에서 불가피하면 ObjectInputFilter로 역직렬화 필터링을 하되, 화이트리스트 방식을 써라.Item 86. Serializable을 구현할지는 신중히 결정하라Serializable을 구현하면 직렬화 형태가 공개 API가 되어 영원히 지원해야 한다. 릴리스마다 직렬화/역직렬화 호환성 테스트 부담이 생기고, 역직렬화는 숨은 생성자로서 불변식 검사를 우회할 수 있다.serialVersionUID를 반드시 명시하라. 상속용 클래스는 Serializable을 구현하지 않는 것이 원칙이다. 내부 클래스(inner class)는 직렬화하지 마라 (정적 멤버 클래스는 가능).Item 87. 커스텀 직렬화 형태를 고려해보라물리적 표현과 논리적 내용이 같을 때만 기본 직렬화를 사용하라. 다르다면 writeObject/readObject를 직접 정의하고 transient로 제외할 필드를 지정하라.// 논리적 내용만 직렬화하는 StringListprivate transient int size = 0;private transient Entry head = null;private void writeObject(ObjectOutputStream s) throws IOException { s.defaultWriteObject(); s.writeInt(size); for (Entry e = head; e != null; e = e.next) s.writeObject(e.data);}defaultWriteObject()/defaultReadObject() 호출은 향후 transient가 아닌 필드 추가 시 호환성을 위해 반드시 포함하라. serialVersionUID를 명시적으로 선언하여 런타임 비용을 줄이고 호환성을 유지하라.Item 88. readObject 메서드는 방어적으로 작성하라readObject는 바이트 스트림을 받는 public 생성자나 다름없다. 방어적 복사 후 유효성 검사를 수행하고, 재정의 가능 메서드를 호출하지 마라.private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException { s.defaultReadObject(); start = new Date(start.getTime()); // 방어적 복사 먼저 end = new Date(end.getTime()); if (start.compareTo(end) > 0) // 유효성 검사 나중에 throw new InvalidObjectException(start + "가 " + end + "보다 늦다.");}Item 89. 인스턴스 수를 통제해야 한다면 readResolve보다는 열거 타입을 사용하라싱글턴 클래스에 implements Serializable을 추가하면 더 이상 싱글턴이 아니다. readResolve로 유지할 수 있지만 모든 참조 필드를 transient로 선언해야 도둑(stealer) 클래스 공격을 방어할 수 있다.가장 좋은 해법은 열거 타입으로 싱글턴을 구현하는 것이다. 자바가 선언된 상수 외에 다른 인스턴스가 만들어지지 않음을 보장한다.public enum Elvis { INSTANCE; private String[] favoriteSongs = {"Hound Dog", "Heartbreak Hotel"}; public void printFavorites() { System.out.println(Arrays.toString(favoriteSongs)); }}Item 90. 직렬화된 인스턴스 대신 직렬화 프록시 사용을 검토하라직렬화 프록시 패턴은 바깥 클래스의 논리적 상태를 private static 중첩 클래스에 복사하고, 역직렬화 시 공개 생성자를 통해 인스턴스를 만든다. 방어적 복사보다 강력하다.sequenceDiagram participant C as Client participant P as Period participant SP as SerializationProxy participant OS as ObjectOutputStream participant IS as ObjectInputStream C->>P: 직렬화 요청 P->>SP: writeReplace() → 프록시 생성 SP->>OS: 프록시만 직렬화 OS->>IS: 바이트 스트림 전송 IS->>SP: 프록시 역직렬화 SP->>P: readResolve() → new Period(start, end) Note over P: 공개 생성자 → 불변식 자동 검사private static class SerializationProxy implements Serializable { private final Date start; private final Date end; SerializationProxy(Period p) { this.start = p.start; this.end = p.end; } private Object readResolve() { return new Period(start, end); // 공개 생성자 → 불변식 자동 검사 }}// 바깥 클래스private Object writeReplace() { return new SerializationProxy(this); }private void readObject(ObjectInputStream s) throws InvalidObjectException { throw new InvalidObjectException("프록시가 필요합니다.");}한계: 확장 가능한 클래스에는 적용 불가, 객체 그래프에 순환이 있으면 사용 불가.참고 자료 Effective Java, 3rd Edition - InformIT JDK 21 Documentation Home - Oracle Java SE 21 API Specification - Oracle The Java Language Specification, Java SE 21 Edition - Oracle Object (Java SE 21 & JDK 21) - Oracle AutoCloseable (Java SE 21 & JDK 21) - Oracle Package java.util.stream (Java SE 21 & JDK 21) - Oracle Package java.util.concurrent (Java SE 21 & JDK 21) - Oracle SplittableRandom (Java SE 21 & JDK 21) - Oracle Java Native Interface Specification - Oracle Java Object Serialization Specification - Oracle JEP 421: Deprecate Finalization for Removal - OpenJDK JEP 395: Records - OpenJDK JEP 361: Switch Expressions - OpenJDK Java Collections Framework Overview - Oracle Project Valhalla (Value Objects) - OpenJDK Project Loom (Inline Classes) - OpenJDK
- Sweep Line Algorithm algorithm sweep-line sorting Sweep Line Sweep LineSweep Line은 이벤트를 한 축 위에 정렬해 두고, 한 방향으로 훑으면서 상태를 갱신하는 기법이다.한 줄로 요약하면 다음과 같다.시작점과 끝점을 이벤트로 바꿔 정렬한 뒤 차례대로 처리한다1. 언제 쓰는가문제에서 아래 표현이 보이면 sweep line을 떠올릴 수 있다. 구간 시작 / 종료 겹치는 구간 수 동시에 열려 있는 회의 수 선분 교차 여부의 단순화 버전 좌표축 위 이벤트 처리 시간 순으로 상태 변화를 추적대표 문제: 최대 동시 접속자 수 필요한 최소 회의실 수 겹치는 구간 개수 직사각형/구간 커버 여부2. 핵심 아이디어구간 [l, r]이 있으면 이것을: 시작 이벤트 +1 종료 이벤트 -1로 바꾼다.그 다음 모든 이벤트를 좌표순으로 정렬해서 보면서현재 활성 구간 수를 관리한다.flowchart LR A["구간 입력"] --> B["시작/끝 이벤트로 변환"] B --> C["이벤트 정렬"] C --> D["왼쪽에서 오른쪽으로 스캔"] D --> E["현재 활성 개수 / 최대값 갱신"]3. 가장 기본 예시: 최대 겹침 수문제:구간들이 주어질 때 동시에 겹치는 구간의 최대 개수는?예:[1,4], [2,5], [4,6]이벤트로 바꾸면:(1, 시작)(4, 끝)(2, 시작)(5, 끝)(4, 시작)(6, 끝)정렬 후 순서대로 보면서 현재 개수를 갱신하면 된다.손으로 따라가기반열린 구간 [l, r)라고 하자.그러면 같은 좌표에서는 끝 이벤트를 먼저 처리한다.정렬된 이벤트는:(1, +1)(2, +1)(4, -1)(4, +1)(5, -1)(6, -1)이제 왼쪽에서 오른쪽으로 훑어 보자.x=1: active = 1x=2: active = 2x=4: active = 1 // [1,4) 종료x=4: active = 2 // [4,6) 시작x=5: active = 1x=6: active = 0따라서 최대 겹침 수는 2다.timeline title Sweep Line Example 1 : [1,4) 시작 2 : [2,5) 시작 4 : [1,4) 종료 / [4,6) 시작 5 : [2,5) 종료 6 : [4,6) 종료이 예시에서 x = 4가 가장 중요하다.끝을 먼저 처리하느냐, 시작을 먼저 처리하느냐에 따라4에서의 active 값이 달라질 수 있기 때문이다.4. 끝점 포함 규칙이 가장 중요하다Sweep line에서 가장 자주 틀리는 부분은같은 좌표에서 시작과 끝이 동시에 있을 때의 처리 순서다.예를 들어 구간을: 닫힌 구간 [l, r] 반열린 구간 [l, r)중 무엇으로 해석하는지에 따라 정렬 기준이 달라진다.보통 코테에서는 다음처럼 많이 처리한다.1) [l, r)로 볼 때같은 좌표라면 끝 이벤트를 먼저 처리한다.이 경우:[1,4) 와 [4,6)는 겹치지 않는다.2) [l, r]로 볼 때같은 좌표라면 시작 이벤트를 먼저 처리해야끝점도 겹침으로 셀 수 있다.즉 문제의 “겹친다” 정의를 먼저 확인해야 한다.같은 예시를 닫힌 구간으로 보면만약 [1,4], [4,6]을 닫힌 구간으로 보면좌표 4에서 실제로 겹친다.그래서 이 경우에는:같은 x에서는 시작 이벤트를 먼저 처리해야 x = 4에서 active가 2가 된다.즉 sweep line은 자료구조보다도문제의 포함 규칙을 코드 정렬 기준으로 옮기는 작업이 핵심이다.5. 최대 동시 구간 수 구현아래 코드는 반열린 구간 [start, end) 기준이다.따라서 같은 좌표에서는 끝 이벤트를 먼저 처리한다.import java.util.*;class SweepLine { static class Event { int x; int type; // -1: end, +1: start Event(int x, int type) { this.x = x; this.type = type; } } int maxOverlap(int[][] intervals) { List<Event> events = new ArrayList<>(); for (int[] interval : intervals) { int start = interval[0]; int end = interval[1]; events.add(new Event(start, +1)); events.add(new Event(end, -1)); } events.sort((a, b) -> { if (a.x != b.x) return Integer.compare(a.x, b.x); return Integer.compare(a.type, b.type); // end(-1) 먼저 }); int active = 0; int answer = 0; for (Event e : events) { active += e.type; answer = Math.max(answer, active); } return answer; }}왜 이 코드가 맞는가이 코드는 어떤 좌표 x를 지날 때마다: 시작 이벤트면 활성 구간 수를 1 증가 종료 이벤트면 활성 구간 수를 1 감소시키고 있다.즉 active는 “현재 sweep line이 지나가고 있는 지점에서 살아 있는 구간 수”를 의미한다.flowchart TD A["이벤트 정렬"] --> B["이벤트 하나 꺼냄"] B --> C{"start or end?"} C -->|start| D["active += 1"] C -->|end| E["active -= 1"] D --> F["answer = max(answer, active)"] E --> F F --> G{"다음 이벤트 있음?"} G -->|Yes| B G -->|No| H["종료"]이 관점으로 보면 sweep line은“이벤트 시점에서 상태가 어떻게 변하는가”를 구현한 것뿐이다.6. 회의실 배정과의 관계“최소 몇 개의 회의실이 필요한가?”도 사실 같은 문제다.아이디어: 회의 시작 -> 방 하나 필요 회의 종료 -> 방 하나 반환즉 최대 동시 활성 회의 수가 곧 정답이다.이 문제는 다음 두 방법 모두 가능하다. sweep line 이벤트 정렬 시작 시간 정렬 + 종료 시간 최소 힙둘 다 정답은 같고, 구현 취향 차이다.회의실 예시회의가 다음과 같다고 하자.[10,20), [15,25), [18,30), [30,40)활성 회의 수를 세면: 10 직후: 1개 15 직후: 2개 18 직후: 3개 20 직후: 2개 25 직후: 1개 30 직후: 1개 ([18,30) 종료 후 [30,40) 시작)최대 동시 개수는 3이고,필요한 최소 회의실 수도 3이다.7. 차분 배열과의 관계좌표 범위가 작고 정수 축이면sweep line을 차분 배열로 바꿀 수도 있다.예:diff[start] += 1diff[end] -= 1그리고 누적합을 취하면 각 좌표의 활성 개수를 알 수 있다.즉: 좌표 범위가 작다 -> 차분 배열 좌표가 크거나 실수다 -> 이벤트 정렬 sweep line처럼 생각하면 된다.즉 차분 배열은 sweep line을“모든 좌표를 직접 순회하는 버전”이라고 볼 수 있다.좌표가 크면 정렬 또는 좌표 압축으로 간다예를 들어 좌표가 1부터 10^9까지 가능하지만실제로 등장하는 시작/끝점은 2 * N개뿐일 수 있다.이때는 배열을 직접 만들면 비효율적이다. 좌표축을 그대로 다 순회해야 한다 -> 차분 배열 부적합 이벤트 좌표만 처리하면 된다 -> 정렬 기반 sweep line 적합 정수 좌표이고 구간 쿼리까지 섞인다 -> 좌표 압축 후 세그먼트 트리/Fenwick Tree 검토즉 sweep line은 단순히 “정렬해서 본다”가 아니라실제로 변화가 일어나는 좌표만 본다는 관점으로 이해하면 좋다.8. 2차원에서도 쓰이는가쓴다.대표적으로: 직사각형 넓이 교차 개수 최근접 이벤트 갱신같은 문제에서 x축으로 sweep 하면서,현재 y축 상태를 세그먼트 트리나 Fenwick Tree로 관리하기도 한다.다만 코테에서는 보통 1차원 이벤트 정렬 형태가 더 자주 나온다.그래서 학습 순서는 보통: 1차원 구간 이벤트 차분 배열과 연결 필요하면 2차원 sweep으로 잡는 편이 좋다.단순 개수만 세는지, 활성 집합 자체가 필요한지도 구분한다지금 문서의 예시처럼 “현재 몇 개가 겹치는가”만 묻는다면active 정수 하나면 충분하다.하지만 문제에 따라서는 현재 활성인 구간들의 더 많은 정보가 필요하다. 가장 먼저 끝나는 구간이 필요하다 -> 최소 힙 현재 활성 구간의 최솟값/최댓값이 필요하다 -> TreeSet, 힙, 세그먼트 트리 활성 구간들의 총 길이나 가중치가 필요하다 -> 더 복잡한 자료구조즉 sweep line의 본질은 “정렬된 이벤트를 순서대로 처리”하는 것이고,그 안에서 유지하는 상태는 문제마다 달라진다.9. 자주 하는 실수 시작/끝이 같은 좌표일 때 tie-break를 잘못 둠 닫힌 구간과 반열린 구간 해석을 섞음 이벤트 정렬 후 갱신 순서를 잘못 둠 좌표가 큰데 배열로 직접 처리하려고 함 active는 맞는데 최대값 갱신 시점을 틀림10. 시험장용 최소 암기 버전Sweep Line:구간을 시작/끝 이벤트로 바꿔 정렬 후 스캔핵심:현재 활성 개수 관리최대/최소/변화 시점 계산주의:같은 좌표에서 시작과 끝 처리 순서구간 포함 규칙 [l,r] / [l,r)11. 최종 요약Sweep line은 다음 문장으로 정리할 수 있다.시간이나 좌표축 위의 변화를 이벤트로 바꿔정렬 한 번과 선형 스캔으로 상태를 관리하는 기법
- SCC Algorithm algorithm scc graph SCC SCCSCC(Strongly Connected Component, 강한 연결 요소)는 방향 그래프에서 서로 왕복 도달 가능한 정점들의 최대 묶음이다.한 줄로 요약하면 다음과 같다.방향 그래프를 "서로 돌아올 수 있는 덩어리"로 압축하는 개념1. 언제 쓰는가문제에서 아래 느낌이 보이면 SCC를 떠올릴 수 있다. 방향 그래프 A에서 B로 가고, B에서 A로도 갈 수 있는가 서로 영향을 주고받는 그룹 순환 의존성 묶기 2-SAT 그래프를 DAG로 압축해서 보고 싶음대표 문제: BOJ SCC 기본 문제 도미노 2-SAT ATM / 경찰서 / 축구 전술 같은 SCC 압축 DAG 문제2. 정확한 정의방향 그래프에서 정점 집합 C가 SCC라는 뜻은: C 안의 임의의 두 정점 u, v에 대해 u -> v 경로가 있고 v -> u 경로도 있다는 뜻이다그리고 더 큰 집합으로 확장할 수 없어야 한다.즉 SCC는 “서로 왔다 갔다 할 수 있는 최대 덩어리”다.3. 왜 중요한가SCC를 하나의 노드로 압축하면 그래프가 DAG가 된다.이것이 핵심이다.복잡한 방향 그래프-> SCC로 묶기-> 압축 DAG에서 다시 DP / 위상 정렬 / 진입차수 분석즉 SCC는 종종 최종 답이 아니라,문제를 한 단계 단순화하는 전처리 역할을 한다.4. 작은 예시그래프:1 -> 2 -> 3 -> 13 -> 44 -> 5 -> 6 -> 4이 그래프의 SCC는: {1, 2, 3} {4, 5, 6}두 묶음이다.압축하면:{1,2,3} -> {4,5,6}라는 DAG가 된다.graph LR A["{1,2,3}"] --> B["{4,5,6}"]원래 그래프를 정점 단위로 그리면 다음 느낌이다.graph LR N1((1)) --> N2((2)) N2 --> N3((3)) N3 --> N1 N3 --> N4((4)) N4 --> N5((5)) N5 --> N6((6)) N6 --> N4여기서 중요한 관찰은: 1, 2, 3 사이에서는 어디서 시작해도 서로 돌아올 수 있다 4, 5, 6도 마찬가지다 하지만 4, 5, 6에서 다시 1, 2, 3으로 돌아오는 길은 없다그래서 두 묶음은 같은 SCC가 될 수 없다.왜 {1,2,3,4,5,6} 전체가 하나가 아닌가1 -> 5는 가능하다.1 -> 2 -> 3 -> 4 -> 5하지만 5 -> 1은 불가능하다.즉 “한쪽 방향으로 갈 수 있다”와 “같은 SCC다”는 전혀 다르다.SCC에서는 항상 다음 두 방향을 모두 확인해야 한다.u -> v 가능v -> u 가능5. Kosaraju 알고리즘 핵심코테에서는 Kosaraju나 Tarjan을 쓴다.학습용으로는 Kosaraju가 더 직관적이다.핵심 흐름: 원래 그래프에서 DFS를 돌며 종료 순서를 기록 간선을 모두 뒤집은 그래프에서 종료 순서 역순으로 DFS 한 번의 DFS에서 방문한 정점들이 하나의 SCC왜 되나? 먼저 끝난 정점부터가 아니라 가장 나중에 끝난 정점부터 역그래프에서 탐색해야 SCC 단위로 깔끔하게 묶인다흐름을 그림으로 보면flowchart TD A["원래 그래프 DFS"] --> B["종료 순서 order 저장"] B --> C["간선 방향 뒤집은 reverse graph 준비"] C --> D["order 역순으로 DFS 시작"] D --> E["한 번에 방문한 정점 = 하나의 SCC"]핵심 감각은 이렇다. 첫 번째 DFS는 “어디부터 꺼내야 SCC가 안 섞이는가”를 정하는 단계 두 번째 DFS는 “실제로 SCC를 뽑아내는 단계”즉 첫 번째 DFS만으로 SCC를 구하는 것이 아니라,두 번째 DFS를 위한 시작 순서를 만드는 것이 핵심 역할이다.왜 역순이 중요한가압축 DAG를 생각해 보자.SCC A -> SCC B이때 원래 그래프 DFS에서는 보통 A 쪽이 더 늦게 끝난다.그래서 종료 순서 역순으로 역그래프를 보면 A부터 시작하게 되고,역그래프에서는 B -> A가 되므로 A에서 출발해도 B로 새지 않는다.이 성질 덕분에 SCC가 정확히 한 덩어리씩 잘린다.graph LR A["SCC A"] --> B["SCC B"]graph LR B["reverse에서 SCC B"] --> A["reverse에서 SCC A"]즉 “원래 그래프 종료 순서 역순 + 역그래프”가 짝으로 묶여야 한다.6. Kosaraju를 손으로 따라가기아까 예시 그래프를 다시 보자.1 -> 2 -> 3 -> 13 -> 44 -> 5 -> 6 -> 41단계: 원래 그래프 DFS예를 들어 1부터 DFS를 시작하면 대략:1 -> 2 -> 3 -> 4 -> 5 -> 6순으로 깊게 들어간다.DFS가 끝나는 순서는 안쪽부터이므로:6, 5, 4, 3, 2, 1이런 식으로 order에 쌓인다.즉 order의 뒤쪽, 다시 말해 역순으로 꺼낼 때는:1, 2, 3, 4, 5, 6순서가 된다.2단계: 역그래프에서 역순 DFS역그래프에서는 간선 방향이 뒤집혀서:1 <- 2 <- 3 <- 14 <- 34 <- 5 <- 6 <- 4가 된다.이제 역순의 첫 정점 1에서 DFS를 하면:1, 3, 2만 방문하고 4, 5, 6 쪽으로는 못 간다.그래서 첫 SCC는:{1, 2, 3}그 다음 아직 방문하지 않은 4에서 시작하면:4, 6, 5가 묶여서 두 번째 SCC:{4, 5, 6}이 된다.이 손추적을 한 번 해 보면,“왜 역그래프에서 역순으로 해야 하는가”가 훨씬 덜 추상적으로 느껴진다.7. Kosaraju 구현import java.util.*;class SCCKosaraju { int n; ArrayList<Integer>[] graph; ArrayList<Integer>[] reverse; boolean[] visited; List<Integer> order = new ArrayList<>(); int[] sccId; List<List<Integer>> components = new ArrayList<>(); SCCKosaraju(int n) { this.n = n; graph = new ArrayList[n + 1]; reverse = new ArrayList[n + 1]; visited = new boolean[n + 1]; sccId = new int[n + 1]; for (int i = 1; i <= n; i++) { graph[i] = new ArrayList<>(); reverse[i] = new ArrayList<>(); } } void addEdge(int u, int v) { graph[u].add(v); reverse[v].add(u); } void dfs1(int cur) { visited[cur] = true; for (int next : graph[cur]) { if (!visited[next]) dfs1(next); } order.add(cur); } void dfs2(int cur, int id, List<Integer> component) { visited[cur] = true; sccId[cur] = id; component.add(cur); for (int next : reverse[cur]) { if (!visited[next]) dfs2(next, id, component); } } List<List<Integer>> build() { for (int i = 1; i <= n; i++) { if (!visited[i]) dfs1(i); } Arrays.fill(visited, false); int id = 0; for (int i = order.size() - 1; i >= 0; i--) { int v = order.get(i); if (visited[v]) continue; List<Integer> component = new ArrayList<>(); dfs2(v, ++id, component); components.add(component); } return components; }}시간 복잡도: O(V + E)원래 그래프와 역그래프를 한 번씩 DFS하므로 선형 시간이다.8. 압축 DAG 만들기SCC를 구한 뒤에는 서로 다른 SCC 사이의 간선만 모으면 된다.Set<Long> edgeSet = new HashSet<>();ArrayList<Integer>[] dag = new ArrayList[sccCount + 1];for (int i = 1; i <= sccCount; i++) dag[i] = new ArrayList<>();for (int u = 1; u <= n; u++) { for (int v : graph[u]) { int a = sccId[u]; int b = sccId[v]; if (a == b) continue; long key = ((long) a << 32) | b; if (edgeSet.add(key)) { dag[a].add(b); } }}이제 dag는 사이클이 없는 그래프이므로: 위상 정렬 DP 진입 차수 분석을 바로 적용할 수 있다.압축 전과 후flowchart LR subgraph BEFORE["압축 전"] A1((1)) --> A2((2)) A2 --> A3((3)) A3 --> A1 A3 --> A4((4)) A4 --> A5((5)) A5 --> A6((6)) A6 --> A4 end subgraph AFTER["압축 후"] B1["SCC 1<br>{1,2,3}"] --> B2["SCC 2<br>{4,5,6}"] end그래프 문제가 SCC 이후 갑자기 쉬워지는 이유가 여기 있다.원래는 사이클 때문에 위상 정렬이나 DAG DP를 못 하지만,SCC로 묶고 나면 압축 그래프는 항상 DAG가 된다.왜 압축 그래프는 반드시 DAG인가이 부분은 결론만 외우기보다 한 번 논리로 이해하는 편이 좋다.만약 압축 그래프에 사이클이 있다고 가정하자.SCC A -> SCC B -> SCC C -> ... -> SCC A그러면: A에서 B로 갈 수 있고 B에서 C로 갈 수 있고 … 다시 A로 돌아올 수 있다즉 이 사이클 위의 모든 SCC는 서로 왕복 도달 가능하다.그런데 SCC는 “서로 왕복 도달 가능한 최대 묶음”이므로,이들은 원래 서로 다른 SCC일 수 없다.모순이므로 압축 그래프에는 사이클이 있을 수 없다.flowchart LR A["서로 다른 SCC라고 가정"] --> B["압축 그래프에 사이클 존재"] B --> C["사이클을 따라 서로 왕복 도달 가능"] C --> D["사실 하나의 SCC여야 함"] D --> E["가정과 모순"]실전에서는 이 성질 하나 때문에“원래 그래프는 복잡하지만 SCC DAG에서는 위상 정렬/DP가 된다”는 판단이 가능해진다.SCC 번호 순서를 너무 믿지 말기Kosaraju 구현에서 sccId는 DFS가 SCC를 발견한 순서대로 붙는다.이 순서가 압축 DAG 흐름과 어느 정도 맞아떨어지는 경우가 많지만,문제 풀이에서는 이 번호 자체를 정렬 순서처럼 가정하지 않는 편이 안전하다.정말 위상 순서가 필요하면: 압축 DAG를 만들고 indegree를 세서 위상 정렬을 한 번 더 수행하는 방식이 가장 명확하다.9. Tarjan 알고리즘은 무엇이 다른가Tarjan은 DFS 한 번으로 SCC를 구한다.핵심 개념: 방문 순서 disc 되돌아갈 수 있는 가장 빠른 번호 low 현재 DFS 스택에 있는 정점 관리실전에서는: 구현 직관성 -> Kosaraju 그래프를 한 번만 돌고 싶음 -> Tarjan정도로 기억해도 충분하다.10. 자주 쓰는 응용1) 사이클이 있는 그룹 묶기순환 의존 관계를 하나의 묶음으로 본다.2) SCC DAG 위 DP각 SCC의 가중치를 합친 뒤 DAG에서 최댓값/최솟값을 구한다.3) 2-SAT변수와 부정 변수를 노드로 두고 SCC로 모순 여부를 판별한다.핵심 규칙:x와 not x가 같은 SCC에 있으면 모순11. 자주 하는 실수 방향 그래프가 아닌데 SCC를 적용하려고 함 Kosaraju에서 역그래프를 안 만듦 첫 번째 DFS의 종료 순서와 두 번째 DFS 순서를 헷갈림 SCC 압축 후 중복 간선을 제거하지 않아 DAG가 지저분해짐 재귀 DFS에서 스택 깊이 문제를 무시함12. 시험장용 최소 암기 버전SCC:서로 왕복 도달 가능한 정점들의 최대 묶음핵심:SCC로 압축하면 DAGKosaraju:1. 원래 그래프 DFS -> 종료 순서 저장2. 역그래프 DFS -> 역순으로 SCC 추출응용:압축 DAG DP2-SAT사이클 그룹 묶기13. 최종 요약SCC는 다음 문장으로 정리할 수 있다.방향 그래프를 서로 왕복 가능한 묶음으로 압축해사이클을 DAG 구조로 정리하는 핵심 도구
- Meet in the Middle Algorithm algorithm meet-in-the-middle brute-force Meet in the Middle Meet in the MiddleMeet in the Middle은 완전탐색을 반으로 쪼개서 지수 시간을 줄이는 기법이다.한 줄로 요약하면 다음과 같다.N개를 한 번에 보지 말고 N/2씩 나눠서 합친다1. 언제 쓰는가문제에서 아래 조건 조합이 보이면 강하게 의심할 수 있다. 원소 수가 N <= 40 정도 부분집합 / 부분수열 / 선택 여부 완전탐색 2^N은 너무 큼 DP를 쓰기엔 값 범위가 너무 큼 합, 차이, 무게 제한, 최대값대표 문제: 부분수열 합 냅색 변형 부분집합 합이 K인지 판별 <= C인 최대 부분합2. 왜 필요한가N = 40이면 전체 부분집합 수는:2^40 ≈ 1조라서 완전탐색이 불가능하다.하지만 반으로 나누면:왼쪽 20개 -> 2^20 ≈ 100만오른쪽 20개 -> 2^20 ≈ 100만이 정도는 계산 가능하다.즉 핵심은:2^N -> 2^(N/2) + 2^(N/2)로 줄이는 데 있다.3. 핵심 아이디어배열을 왼쪽 절반, 오른쪽 절반으로 나눈다.그리고: 왼쪽 절반에서 만들 수 있는 모든 결과를 구한다 오른쪽 절반에서도 모든 결과를 구한다 두 리스트를 정렬 / 이분 탐색 / 투 포인터 / 해시로 결합한다이렇게 하면 원래의 큰 탐색을 두 개의 작은 탐색으로 바꿀 수 있다.flowchart LR A["원본 배열 N개"] --> B["왼쪽 N/2개"] A --> C["오른쪽 N/2개"] B --> D["왼쪽 결과 전부 생성"] C --> E["오른쪽 결과 전부 생성"] D --> F["정렬 / 해시 / 투 포인터"] E --> F F --> G["최종 답"]핵심은 “탐색 자체를 없애는 것”이 아니라“큰 탐색 1번을 작은 탐색 2번 + 결합”으로 바꾸는 것이다.4. 가장 대표적인 형태: 부분집합 합예를 들어 배열이 있고:합이 C 이하인 부분집합 중 최대 합을 구한다고 하자.왼쪽 절반 부분합 리스트를 A,오른쪽 절반 부분합 리스트를 B라고 하자.그러면 답은:A의 어떤 합 + B의 어떤 합 <= C를 만족하는 최댓값이다.예를 들어:arr = [3, 5, 6, 7], C = 10라고 하자.절반으로 나누면: 왼쪽 [3, 5] 오른쪽 [6, 7]이다.왼쪽 부분합은:0, 3, 5, 8오른쪽 부분합은:0, 6, 7, 13이제 왼쪽의 각 값에 대해 오른쪽에서 최대한 큰 값을 붙인다. 0에는 10 이하인 오른쪽 최대 7 -> 합 7 3에는 7 이하인 오른쪽 최대 7 -> 합 10 5에는 5 이하인 오른쪽 최대 0 -> 합 5 8에는 2 이하인 오른쪽 최대 0 -> 합 8정답은 10이다.원래 2^4 = 16개 부분집합을 다 봐도 되지만,N이 커지면 이 방식을 절반 분할로 일반화해야 한다.5. 부분합 생성재귀나 비트마스크로 만들 수 있다.void generateSums(int[] arr, int idx, int end, long sum, List<Long> out) { if (idx == end) { out.add(sum); return; } generateSums(arr, idx + 1, end, sum, out); generateSums(arr, idx + 1, end, sum + arr[idx], out);}[idx, end) 구간의 모든 부분집합 합이 out에 들어간다.생성 과정을 트리로 보면왼쪽 절반 [3, 5]의 부분합 생성은 다음처럼 볼 수 있다.flowchart TD A["sum=0, idx=0"] --> B["3 선택 안 함"] A --> C["3 선택함 -> sum=3"] B --> D["5 선택 안 함 -> 0"] B --> E["5 선택함 -> 5"] C --> F["5 선택 안 함 -> 3"] C --> G["5 선택함 -> 8"]즉 각 원소마다: 선택 안 함 선택함두 갈래로 분기하고,리프에 도달했을 때의 합을 리스트에 넣는다.이 때문에 절반 길이가 m이면 결과 개수는 정확히 2^m개다.6. 최대 부분합 <= C 구현import java.util.*;class MeetInTheMiddle { void generateSums(int[] arr, int idx, int end, long sum, List<Long> out) { if (idx == end) { out.add(sum); return; } generateSums(arr, idx + 1, end, sum, out); generateSums(arr, idx + 1, end, sum + arr[idx], out); } long maxSubsetSumAtMostC(int[] arr, long c) { int n = arr.length; int mid = n / 2; List<Long> left = new ArrayList<>(); List<Long> right = new ArrayList<>(); generateSums(arr, 0, mid, 0, left); generateSums(arr, mid, n, 0, right); Collections.sort(right); long answer = 0; for (long x : left) { if (x > c) continue; long remain = c - x; int idx = upperBound(right, remain) - 1; if (idx >= 0) { answer = Math.max(answer, x + right.get(idx)); } } return answer; } int upperBound(List<Long> list, long target) { int left = 0; int right = list.size(); while (left < right) { int mid = left + (right - left) / 2; if (list.get(mid) > target) right = mid; else left = mid + 1; } return left; }}시간 복잡도는 대략:O(2^(N/2) log 2^(N/2))이다.더 정확히 보면: 왼쪽 부분합 생성: O(2^(N/2)) 오른쪽 부분합 생성: O(2^(N/2)) 오른쪽 정렬: O(2^(N/2) log 2^(N/2)) 왼쪽 각 합마다 이분 탐색: O(2^(N/2) log 2^(N/2))그래서 전체는 결국 같은 차수다.메모리도 왼쪽/오른쪽 부분합을 들고 있어야 하므로:O(2^(N/2))가 필요하다.answer = 0으로 두면 안 되는 경우Meet in the Middle에서 자주 나오는 실수다.long answer = 0;로 시작하면 다음 상황에서 틀릴 수 있다. 공집합 선택이 허용되지 않는 문제 C가 음수일 수 있는 문제 원소에 음수가 섞여 있어서 최적합이 음수일 수 있는 문제즉 0은 “아직 답을 못 찾음”이 아니라“이미 합 0을 만드는 유효한 해를 찾음”이라는 의미가 되어 버릴 수 있다.보다 안전하게는 found 플래그를 두고,문제의 불가능 처리 규칙에 맞춰 반환하는 편이 낫다.long answer = Long.MIN_VALUE;boolean found = false;for (long x : left) { long remain = c - x; int idx = upperBound(right, remain) - 1; if (idx >= 0) { answer = Math.max(answer, x + right.get(idx)); found = true; }}if (!found) { // 문제 조건에 맞춰 처리: // 예) 불가능 표시, 예외, 별도 sentinel 반환}반대로 “공집합 허용 + 모든 원소 비음수 + C >= 0“이 보장되면answer = 0 초기화도 자연스럽다.7. 왜 이분 탐색을 쓰는가왼쪽 부분합 x를 하나 고르면,오른쪽에서는:<= C - x인 값 중 가장 큰 것을 고르면 된다.그래서 오른쪽 부분합을 정렬해 두고 upper bound를 찾는다.손으로 따라가기오른쪽 부분합이 정렬되어 있다고 하자.right = [0, 6, 7, 13]왼쪽에서 x = 3을 골랐다면:remain = 10 - 3 = 7이제 오른쪽에서 <= 7인 가장 큰 값을 찾으면 되므로upperBound(7)의 바로 왼쪽 원소를 고르면 된다.upperBound(7) = 3-> index 2-> right[2] = 7즉:3 + 7 = 10이 된다.이렇게 “왼쪽 하나 고정 + 오른쪽 최선 찾기” 구조가 되기 때문에정렬 + 이분 탐색이 자연스럽다.8. 정확히 K를 만드는가, 개수를 세는가Meet in the Middle은 최댓값만 구하는 데 쓰는 것이 아니다.다음에도 자주 나온다.1) 합이 정확히 K인 경우 존재 여부 한쪽 부분합을 해시셋에 넣고 다른 쪽에서 K - x를 찾는다2) 합이 정확히 K인 부분집합 개수 한쪽 부분합 빈도를 정렬/맵으로 관리하고 다른 쪽과 결합한다중요한 점은 존재 여부와 개수 세기가 다르다는 것이다. 존재 여부만 필요하면 HashSet으로 충분할 수 있다 개수를 세야 하면 같은 부분합이 몇 번 나오는지까지 반영해야 한다정렬된 리스트를 쓴다면 lowerBound, upperBound 차이로 개수를 셀 수 있다.long count = 0;for (long x : left) { long target = k - x; int lo = lowerBound(right, target); int hi = upperBound(right, target); count += (hi - lo);}즉 exact count 문제에서 HashSet만 쓰면중복 부분합 개수를 잃어버려 오답이 될 수 있다.3) 모든 쌍의 차이 최소화 두 리스트를 정렬한 뒤 투 포인터를 쓸 수 있다즉 Meet in the Middle은“반으로 나누고, 결합은 문제 성질에 맞는 도구로 한다”가 핵심이다.결합 도구를 고르는 기준flowchart TD A["왼쪽/오른쪽 결과 생성 완료"] --> B{"무엇을 구하나?"} B -->|존재 여부| C["HashSet / Binary Search"] B -->|최대값/최솟값| D["정렬 + Binary Search"] B -->|쌍 개수| E["정렬 + 빈도 계산 또는 Map"] B -->|차이 최소| F["정렬 + Two Pointer"]즉 Meet in the Middle은 하나의 완성 알고리즘이라기보다,“탐색 공간 절반 분할”이라는 프레임이라고 보는 편이 정확하다.9. DP와 비교부분합 문제라고 해서 항상 DP는 아니다.예: N은 작고 값 범위가 매우 큼 -> Meet in the Middle 값 범위가 작고 N이 큼 -> DP 가능즉 판단 기준은 보통 이렇다.N이 작고 값 범위가 크다 -> Meet in the MiddleN이 크고 합 범위가 작다 -> DP예를 들어: N = 40, 값이 최대 10^9 -> 합 범위 DP는 거의 불가능, Meet in the Middle 유리 N = 100, 합 한도가 100000 -> 배낭 DP 가능즉 N과 값 범위를 같이 봐야 한다.10. 자주 하는 실수 절반을 나눴는데 한쪽만 정렬하고 결합 논리를 안 세움 부분합 개수가 2^(N/2)라는 점을 무시하고 메모리를 과소평가함 int로 합을 저장하다 overflow 공집합 포함 여부를 문제 조건과 다르게 처리함 “정확히 K” 문제에서 중복 부분합 개수 처리를 빠뜨림특히 공집합은 정말 자주 틀린다.예를 들어 “원소를 하나 이상 선택” 조건이 있는데기본 부분합 생성이 0을 포함하면,답이 1개 더 세지거나 잘못된 최댓값이 나올 수 있다.11. 시험장용 최소 암기 버전Meet in the Middle:완전탐색을 반으로 나눠 줄이는 기법언제:N <= 40부분집합 / 선택 문제2^N은 너무 큼흐름:1. 절반씩 나눔2. 각 절반의 모든 결과 생성3. 정렬 / 이분 탐색 / 해시 / 투 포인터로 결합12. 최종 요약Meet in the Middle은 다음 문장으로 정리할 수 있다.완전탐색을 정면으로 하지 않고,반으로 쪼개서 결합 비용으로 바꾸는 지수 탐색 최적화 기법
- Union-Find Algorithm algorithm union-find Union-Find Union-FindUnion-Find(Disjoint Set Union, DSU)는 원소들이 어떤 집합에 속해 있는지 빠르게 관리하는 자료구조다.한 줄로 요약하면 다음과 같다.합칠 집합은 합치고같은 집합인지 빠르게 확인하는 자료구조1. 언제 쓰는가문제에서 아래 표현이 보이면 Union-Find를 먼저 떠올리면 된다. 같은 그룹인가? 네트워크 연결 여부 친구 관계 묶기 간선이 추가될 때 연결 상태 관리 사이클 판별 최소 스패닝 트리 KruskalUnion-Find는 그래프 자체를 탐색하는 자료구조가 아니라,연결 요소를 빠르게 합치고 대표를 찾는 자료구조다.즉 DFS/BFS처럼 경로를 따라가는 도구가 아니라,집합 구조를 관리하는 도구다.2. 핵심 연산은 두 개뿐이다1) find(x)x가 속한 집합의 대표(root)를 찾는다.2) union(a, b)a와 b가 속한 두 집합을 하나로 합친다.이 두 개만 있으면 다음이 가능하다.find(a) == find(b)이면 같은 집합,아니면 다른 집합이다.3. 자료구조 핵심 아이디어parent[i] 배열 하나로 집합을 트리처럼 표현한다.초기에는 각 원소가 자기 자신을 부모로 가진다.1 2 3 4 5초기 상태:parent[1] = 1parent[2] = 2parent[3] = 3parent[4] = 4parent[5] = 5즉 모두 서로 다른 집합이다.만약 union(1, 2)를 하면,예를 들어 2의 부모를 1로 둬서:1 <- 2처럼 묶을 수 있다.또 union(2, 3)을 하면 3도 결국 1 쪽 집합으로 들어간다.핵심은:같은 집합의 원소들은 결국 같은 루트를 가진다는 점이다.4. makeSet 초기화모든 원소를 독립된 집합으로 시작한다.int[] parent = new int[n + 1];for (int i = 1; i <= n; i++) { parent[i] = i;}즉 처음에는 각 원소가 자기 집합의 대표다.5. find 연산가장 단순한 find는 루트가 나올 때까지 부모를 타고 올라가는 것이다.기본형int find(int x) { if (parent[x] == x) return x; return find(parent[x]);}예를 들어:parent[3] = 2parent[2] = 1parent[1] = 1이면 find(3)은 1을 반환한다.즉 3이 속한 집합의 대표는 1이다.6. union 연산union(a, b)는 다음 순서로 생각하면 된다. a의 루트를 찾는다 b의 루트를 찾는다 루트가 다르면 한쪽 루트를 다른 쪽 루트 밑에 붙인다기본형boolean union(int a, int b) { int ra = find(a); int rb = find(b); if (ra == rb) return false; parent[rb] = ra; return true;}반환값을 boolean으로 두는 이유는 실전에서 매우 편하기 때문이다. 이미 같은 집합이면 false 실제로 합쳐졌으면 true이렇게 하면 Kruskal이나 사이클 판별에서 바로 쓸 수 있다.7. 왜 느려질 수 있는가기본형 그대로만 쓰면 트리가 한쪽으로 길게 늘어질 수 있다.예:1 <- 2 <- 3 <- 4 <- 5 <- 6이런 구조가 되면 find(6)은 루트를 찾기 위해 계속 타고 올라가야 한다.즉 거의 선형 시간이 걸릴 수 있다.그래서 실전에서는 반드시 최적화를 넣는다.8. 경로 압축 Path Compression경로 압축은 find를 하면서 지나간 노드들을 루트에 직접 연결하는 기법이다.최적화된 findint find(int x) { if (parent[x] == x) return x; return parent[x] = find(parent[x]);}예를 들어:1 <- 2 <- 3 <- 4에서 find(4)를 한 번 수행하면,이후에는 다음처럼 압축된다.1 <- 21 <- 31 <- 4즉 나중부터는 find가 훨씬 빨라진다.flowchart LR subgraph BEFORE["압축 전"] A4["4"] --> A3["3"] A3 --> A2["2"] A2 --> A1["1"] end subgraph AFTER["압축 후"] B4["4"] --> B1["1"] B3["3"] --> B1 B2["2"] --> B1 end이 다이어그램이 경로 압축의 핵심이다. 한 번 find를 하고 나면 이후 탐색이 루트에 거의 바로 닿게 된다.9. Union by Size / Rank또 하나의 최적화는 트리를 합칠 때 아무렇게나 붙이지 않는 것이다.핵심 아이디어:작은 트리를 큰 트리 밑으로 붙이자이렇게 하면 트리 높이가 크게 늘어나는 것을 막을 수 있다.보통 두 가지 기준을 쓴다. size: 집합 크기 rank: 트리 높이에 대한 근사값실전에서는 size 기준이 직관적이라 많이 쓴다.10. 실전용 DSUstatic class DSU { int[] parent; int[] size; DSU(int n) { parent = new int[n + 1]; size = new int[n + 1]; for (int i = 1; i <= n; i++) { parent[i] = i; size[i] = 1; } } int find(int x) { if (parent[x] == x) return x; return parent[x] = find(parent[x]); } boolean union(int a, int b) { int ra = find(a); int rb = find(b); if (ra == rb) return false; if (size[ra] < size[rb]) { int tmp = ra; ra = rb; rb = tmp; } parent[rb] = ra; size[ra] += size[rb]; return true; }}이 버전이 실전 표준에 가깝다.11. 시간 복잡도는 왜 거의 O(1)처럼 느껴지는가경로 압축 + union by size/rank를 함께 쓰면,각 연산의 시간 복잡도는 엄밀히는:O(alpha(n))이다.여기서 alpha(n)은 아커만 역함수인데,실전에서는 거의 상수처럼 생각해도 된다.즉 Union-Find는 매우 빠르다.12. 사이클 판별에 어떻게 쓰는가무방향 그래프에서 간선을 하나씩 볼 때,간선 (u, v)를 추가하려고 하는데 이미 u와 v가 같은 집합이면 사이클이 생긴다.왜냐하면: 이미 두 정점이 연결되어 있는데 거기에 다시 간선을 추가하면 닫힌 경로가 생기기 때문이다Java 예시if (find(u) == find(v)) { // 사이클 발생} else { union(u, v);}이 패턴은 아주 중요하다.13. Kruskal 알고리즘에서의 역할Union-Find가 가장 유명하게 쓰이는 곳이 Kruskal MST다.Kruskal은 간선을 가중치 순으로 보면서: 사이클이 생기지 않으면 선택 사이클이 생기면 버린다이때 사이클 여부를 빠르게 판별하는 도구가 Union-Find다.흐름 간선을 비용 오름차순으로 정렬 간선을 하나씩 확인 두 정점이 다른 집합이면 union하고 채택 같은 집합이면 버림즉 Kruskal에서 Union-Find는 사실상 필수다.Kruskal MST 구현import java.util.*;class Edge implements Comparable<Edge> { int from, to, cost; Edge(int from, int to, int cost) { this.from = from; this.to = to; this.cost = cost; } public int compareTo(Edge o) { return Integer.compare(this.cost, o.cost); }}long kruskal(int n, List<Edge> edges) { Collections.sort(edges); DSU dsu = new DSU(n); long totalCost = 0; int edgeCount = 0; for (Edge e : edges) { if (dsu.union(e.from, e.to)) { totalCost += e.cost; edgeCount++; if (edgeCount == n - 1) break; } } return totalCost;}핵심 포인트: 간선을 비용순 정렬 union 성공 시 채택 N - 1개 간선이 채택되면 MST 완성flowchart TD A["간선을 비용 순 정렬"] --> B["가장 싼 간선 선택"] B --> C{"같은 컴포넌트?"} C -->|Yes| D["건너뛰기"] C -->|No| E["Union 후 MST에 추가"] D --> F{"N-1개 간선?"} E --> F F -->|No| B F -->|Yes| G["MST 완성"]Union-Find가 빠른 이유는,Kruskal이 매 간선마다 해야 하는 “이미 같은 집합인가?” 판별을 거의 상수 시간에 처리해 주기 때문이다.14. 연결 요소 개수 관리Union-Find로 연결 요소 개수도 쉽게 관리할 수 있다.처음에는 노드 수만큼 집합이 있고,성공적으로 union할 때마다 연결 요소 개수를 1 줄이면 된다.예시int components = n;if (union(a, b)) { components--;}이 방식은: 네트워크 개수 섬 개수 병합 그룹 수 계산문제에서 자주 쓰인다.15. 집합에 추가 정보도 같이 저장할 수 있다Union-Find의 강점은 단순 연결 여부만이 아니다.루트에 집합 정보를 같이 저장하면 다양한 응용이 가능하다.예: 집합 크기 최소 비용 최대값 합계 루트 대표의 특수 정보예를 들어 최소 비용을 루트에 저장하면,“각 그룹의 최소 비용 합” 같은 문제를 쉽게 풀 수 있다.16. 그룹별 최소 비용 예시정의:int[] minCost;각 루트가 자기 집합의 최소 비용을 들고 있다고 하자.Java 예시boolean union(int a, int b) { int ra = find(a); int rb = find(b); if (ra == rb) return false; if (size[ra] < size[rb]) { int tmp = ra; ra = rb; rb = tmp; } parent[rb] = ra; size[ra] += size[rb]; minCost[ra] = Math.min(minCost[ra], minCost[rb]); return true;}이렇게 하면 각 집합이 합쳐질 때 정보도 함께 갱신된다.중요한 점:집계값은 루트 기준으로만 믿는다즉 루트가 아닌 노드의 값은 의미가 없을 수 있다.17. DFS/BFS와 언제 다르게 쓰는가Union-Find와 DFS/BFS는 비슷해 보이지만 목적이 다르다.DFS/BFS가 더 자연스러운 경우 실제 경로를 따라가야 한다 연결된 정점을 모두 방문해야 한다 탐색 순서가 중요하다 최단 거리나 순회가 필요하다Union-Find가 더 자연스러운 경우 같은 그룹인지 빠르게 판단해야 한다 간선 추가가 계속 일어난다 집합 병합만 중요하다 사이클 여부만 빠르게 확인하면 된다즉 Union-Find는 탐색이 아니라 집합 관리다.18. 자주 하는 실수1) union 전에 find를 안 함a, b 자체를 바로 붙이면 안 된다.항상 루트끼리 붙여야 한다.2) 경로 압축을 안 넣음입력 크기가 크면 시간 차이가 크게 난다.3) 루트가 아닌 노드의 집계값을 사용함크기, 최소값, 합계 같은 값은 루트 기준으로만 관리하는 경우가 많다.4) 0-based와 1-based 인덱스를 섞음코테에서 매우 자주 나는 실수다.5) 방향 그래프 문제에 무작정 사용함Union-Find는 보통 무방향 연결성 관리에 쓰인다.방향 그래프의 도달 가능성 문제와는 다르다.6) 이미 같은 집합인데 또 union한 뒤 정보 갱신을 해 버림ra == rb면 바로 종료해야 한다.19. 실전 판단 기준문제에서 아래 표현이 보이면 거의 DSU를 의심하면 된다. 그룹을 합친다 친구 관계를 묶는다 네트워크를 연결한다 간선을 추가할 때 사이클을 검사한다 같은 팀인가 확인한다 최소 스패닝 트리그리고 다음 질문을 해 보면 된다.내가 필요한 것은 경로 탐색인가,아니면 같은 집합인지 빠르게 판단하는 것인가?답이 후자라면 Union-Find일 가능성이 높다.20. 시험장용 최소 암기 버전Union-Find:find(x) = 루트 찾기union(a, b) = 집합 합치기핵심:같은 집합인지 빠르게 확인기본 판별:find(a) == find(b)최적화:경로 압축union by size/rank대표 사용처:사이클 판별Kruskal연결 요소 관리21. 최종 요약Union-Find는 다음 문장으로 정리할 수 있다.원소들이 어떤 집합에 속해 있는지를 빠르게 관리하면서집합 병합과 같은 집합 판별을 효율적으로 처리하는 자료구조핵심만 다시 압축하면: find로 루트를 찾고 union으로 집합을 합친다 같은 집합 여부는 루트가 같은지로 판별한다 경로 압축과 union by size/rank가 핵심 최적화다 사이클 판별, Kruskal, 연결 요소 관리에서 매우 자주 쓴다 집합 크기, 최소 비용 같은 정보도 루트에 함께 저장할 수 있다문제를 보면 먼저 이 질문을 하면 된다.경로를 알아야 하는가,아니면 같은 그룹인지 빠르게만 알면 되는가?후자라면 DSU가 정답일 가능성이 높다.
- Two Pointer Algorithm algorithm two-pointer Two Pointer Two Pointer투 포인터(Two Pointer)는 배열이나 문자열에서 두 개의 위치를 움직이며 조건을 만족하는 구간이나 쌍을 찾는 기법이다.한 줄로 요약하면 다음과 같다.두 개의 포인터를 적절히 움직여불필요한 중복 탐색을 줄이는 선형 시간 기법1. 언제 쓰는가문제에서 아래 표현이 보이면 투 포인터를 의심하면 된다. 연속된 부분 배열 구간 합 합이 M인 두 수 양끝에서 좁혀 오기 정렬된 배열에서 조건을 만족하는 쌍 찾기 최소 길이 구간, 최대 길이 구간투 포인터는 크게 두 종류로 나뉜다. 유형 포인터 움직임 대표 문제 슬라이딩 윈도우 같은 방향 연속 부분합, 최소 길이 구간 양끝 투 포인터 서로 반대 방향 정렬된 배열의 두 수의 합 즉, 둘 다 왼쪽에서 오른쪽으로 밀리면 슬라이딩 윈도우 하나는 왼쪽, 하나는 오른쪽에서 오면 양끝 투 포인터2. 핵심 아이디어투 포인터의 핵심은 다음과 같다.한 번 확인한 구간 정보를 버리지 않고포인터를 조금씩 움직이며 다음 상태를 만든다예를 들어 구간 [start, end]의 합을 알고 있는데,다음 구간이 [start, end + 1]이라면 처음부터 다시 더할 필요가 없다.sum += arr[end + 1];만 하면 된다.즉 투 포인터는: 이전 계산 결과를 재활용하고 포인터를 단조롭게 이동시켜 전체를 O(n) 또는 O(n log n) 수준으로 줄이는 기법이다.3. 슬라이딩 윈도우와 투 포인터의 관계실전에서는 둘을 넓게 묶어서 모두 투 포인터라고 부르기도 한다.하지만 학습할 때는 구분해 두는 편이 좋다.1) 슬라이딩 윈도우 보통 연속된 구간을 다룬다 start, end가 둘 다 오른쪽으로만 이동한다 구간 합, 구간 길이, 구간 내 조건 유지 문제에 자주 나온다2) 양끝 투 포인터 보통 정렬된 배열에서 쌍을 찾는다 left는 오른쪽으로, right는 왼쪽으로 이동한다 두 수의 합, 차이, 조건 만족 쌍 개수 문제에 자주 나온다이 둘은 포인터를 움직인다는 점은 같지만,문제를 보는 관점은 꽤 다르다.flowchart TD A["Two Pointer"] --> B{"연속 부분 배열"} B -->|Yes| C["Sliding Window"] B -->|No| D{"정렬된 쌍 탐색"} D -->|Yes| E["왼쪽/오른쪽 포인터"] D -->|No| F["다른 방법 사용"]즉 투 포인터를 쓸지 판단할 때는 먼저 “연속 구간 문제인가”와 “정렬된 쌍 문제인가”를 구분하는 것이 가장 빠르다.4. 슬라이딩 윈도우의 핵심 조건슬라이딩 윈도우는 아무 문제에나 되는 것이 아니다.가장 중요한 전제는 다음이다.포인터를 움직일 때 구간의 상태가 예측 가능해야 한다특히 구간 합 문제에서 많이 쓰이는 전제는:배열 원소가 음수가 없을 때이다.왜냐하면: end를 오른쪽으로 늘리면 합이 커지거나 같아지고 start를 오른쪽으로 줄이면 합이 작아지거나 같아진다즉 합의 변화 방향이 예측 가능하다.반대로 음수가 섞이면: 구간을 늘렸는데 합이 줄 수도 있고 줄였는데 합이 커질 수도 있다그래서 일반적인 슬라이딩 윈도우 논리가 깨진다.반례: arr = [3, -2, 5], target ≥ 4윈도우 [3,-2] → sum=1 < 4 → end 확장윈도우 [3,-2,5] → sum=6 ≥ 4 → start 줄임윈도우 [-2,5] → sum=3 < 4 → end 이동 불가→ 정답 구간 [5] (sum=5) 를 놓침!음수 때문에 start를 줄이면 합이 커지는 상황 발생→ 단조성이 깨져서 투 포인터 불가이 부분이 가장 중요하다.5. 고정 길이 슬라이딩 윈도우가장 쉬운 형태다.문제 예시:길이가 K인 연속 부분 배열의 합 중 최댓값을 구하라아이디어: 첫 구간 합을 구한다 한 칸 옮길 때마다 왼쪽 값 하나 빼고 오른쪽 값 하나 더한다 int maxWindowSum(int[] arr, int k) { int n = arr.length; int sum = 0; for (int i = 0; i < k; i++) { sum += arr[i]; } int answer = sum; for (int i = k; i < n; i++) { sum += arr[i]; sum -= arr[i - k]; answer = Math.max(answer, sum); } return answer;}이 방식은 길이가 고정이므로 가장 단순하다.6. 가변 길이 슬라이딩 윈도우이번에는 구간 길이가 고정되지 않은 경우다.문제 예시:합이 M 이상이 되는 가장 짧은 연속 부분 배열 길이이 경우는 보통: end를 늘리면서 조건을 만족시킨 뒤 조건을 만족하는 동안 start를 줄여 최소 길이를 갱신하는 구조가 된다.가변 윈도우 동작:arr = [2, 3, 1, 2, 4, 3], target ≥ 7step 1: [2] sum=2 < 7 → end 확장step 2: [2,3] sum=5 < 7 → end 확장step 3: [2,3,1] sum=6 < 7 → end 확장step 4: [2,3,1,2] sum=8 ≥ 7 → 길이 4 기록, start 줄임step 5: [3,1,2] sum=6 < 7 → end 확장step 6: [3,1,2,4] sum=10 ≥ 7 → 길이 4 기록, start 줄임step 7: [1,2,4] sum=7 ≥ 7 → 길이 3 기록, start 줄임step 8: [2,4] sum=6 < 7 → end 확장step 9: [2,4,3] sum=9 ≥ 7 → 길이 3, start 줄임step10: [4,3] sum=7 ≥ 7 → 길이 2 기록 ← 최솟값int minLengthAtLeastS(int[] arr, int s) { int n = arr.length; int start = 0; int sum = 0; int answer = Integer.MAX_VALUE; for (int end = 0; end < n; end++) { sum += arr[end]; while (sum >= s) { answer = Math.min(answer, end - start + 1); sum -= arr[start++]; } } return answer == Integer.MAX_VALUE ? 0 : answer;}핵심은 다음과 같다.조건을 만족하지 않으면 end를 늘리고조건을 만족하면 start를 줄인다flowchart TD A["start=0, end=0, sum=0"] --> B["sum += arr[end]"] B --> C{"sum ≥ target?"} C -->|No| D["end++"] D --> B C -->|Yes| E["길이 갱신"] E --> F["sum -= arr[start]"] F --> G["start++"] G --> C이 흐름이 가변 윈도우의 핵심 루프다. end를 늘려 조건을 만족시킨 뒤, start를 줄여 최소 길이를 탐색한다.7. 합이 정확히 M인 연속 부분 배열 개수배열 원소가 모두 양수일 때 자주 나오는 문제다.아이디어: end를 늘리며 합을 키운다 합이 너무 크거나 같아지면 start를 줄이며 조정한다 합이 정확히 M일 때 개수를 센다int countSubarraysSumM(int[] arr, int m) { int n = arr.length; int start = 0; int sum = 0; int count = 0; for (int end = 0; end < n; end++) { sum += arr[end]; while (sum >= m) { if (sum == m) count++; sum -= arr[start++]; } } return count;}주의:이 방식은 배열 원소가 양수일 때만 자연스럽게 성립한다.음수가 있으면 보통 Prefix Sum + HashMap 쪽으로 가야 한다.8. 양끝 투 포인터의 핵심이번에는 포인터가 서로 반대 방향으로 움직이는 경우다.대표 문제:정렬된 배열에서 합이 M이 되는 두 수를 찾아라배열이 정렬되어 있다고 하자. 합이 작으면 더 큰 값이 필요하므로 left++ 합이 크면 더 작은 값이 필요하므로 right--이렇게 하면 모든 쌍을 다 보지 않고도 답을 찾을 수 있다.9. 왜 정렬이 중요할까양끝 투 포인터는 정렬이 핵심 전제다.정렬되어 있어야: 왼쪽 포인터를 오른쪽으로 옮기면 값이 커지고 오른쪽 포인터를 왼쪽으로 옮기면 값이 작아진다즉 포인터를 어떻게 움직여야 하는지가 논리적으로 결정된다.정렬이 안 되어 있으면: left++가 값을 키운다는 보장이 없고 right--가 값을 줄인다는 보장이 없다그래서 일반적인 양끝 투 포인터는 성립하지 않는다.10. 두 수의 합: 존재 여부 찾기import java.util.*;class Solution { boolean hasPairSum(int[] arr, int target) { Arrays.sort(arr); int left = 0; int right = arr.length - 1; while (left < right) { int sum = arr[left] + arr[right]; if (sum == target) { return true; } else if (sum < target) { left++; } else { right--; } } return false; }}이 코드는 가장 기본형이다.다만 중복 값 처리 방식은 문제에 따라 달라질 수 있다.예를 들어: 서로 다른 인덱스 쌍 개수를 세는지 중복 수를 어떻게 세는지 한 쌍만 찾으면 되는지를 반드시 확인해야 한다.특히 “개수”를 세는 문제는 정의를 먼저 확인해야 한다. 한 쌍이라도 존재하는가 -> 위 코드면 충분 모든 인덱스 쌍 개수를 세는가 -> 같은 값이 연속된 구간 길이까지 같이 처리해야 함 원소를 한 번씩만 쓰는 최대 매칭 개수인가 -> sum == target일 때 left++, right-- 방식이 맞음즉 양끝 투 포인터의 뼈대는 같아도, sum == target일 때 포인터를 어떻게 움직일지는 문제 정의에 따라 달라진다.11. 작은 예시로 이해하기배열:1 2 4 7 11 15목표 합:15초기: left = 0 -> 1 right = 5 -> 15 합 = 16합이 크므로 right-- left = 0 -> 1 right = 4 -> 11 합 = 12합이 작으므로 left++ left = 1 -> 2 right = 4 -> 11 합 = 13합이 작으므로 left++ left = 2 -> 4 right = 4 -> 11 합 = 15정답 발견.이 과정에서 모든 조합을 다 보지 않았다는 점이 핵심이다.12. 슬라이딩 윈도우와 누적합의 차이둘 다 구간 합 문제에 자주 나온다.하지만 쓰이는 상황이 조금 다르다.누적합이 더 자연스러운 경우 여러 개의 구간 합 쿼리 구간 합을 빠르게 반복 조회 음수가 있어도 상관없음슬라이딩 윈도우가 더 자연스러운 경우 포인터를 움직이며 한 번에 답을 찾음 양수 배열의 최소 길이/개수 문제 연속 구간의 상태를 실시간 유지예를 들어: “구간 [L, R] 합을 많이 물어본다” -> 누적합 “조건을 만족하는 가장 짧은 연속 구간” -> 슬라이딩 윈도우13. 음수가 있으면 왜 위험한가예를 들어 배열이 다음과 같다고 하자.3 -2 5현재 합이 크다고 해서 왼쪽을 줄이면 합이 무조건 감소하는가?그렇지 않다.현재 합이 작다고 해서 오른쪽을 늘리면 합이 무조건 증가하는가?그렇지도 않다.즉,포인터 이동 -> 합의 방향 변화가 단조롭지 않다.그래서 음수가 섞인 구간 합 문제에서는 보통: 누적합 해시맵 이분 탐색 덱같은 다른 기법을 생각해야 한다.14. 투 포인터가 O(n)인 이유슬라이딩 윈도우에서 start, end는 둘 다 오른쪽으로만 이동한다.즉 각 포인터는 배열 끝까지 최대 한 번씩만 간다.그래서 전체 이동 횟수는 많아야 2n 정도다.양끝 투 포인터도 마찬가지다. left는 오른쪽으로만 이동 right는 왼쪽으로만 이동따라서 전체가 선형 시간에 가깝게 끝난다.이게 모든 쌍을 다 보는 O(n^2)와 가장 큰 차이다.15. fast/slow 포인터도 넓게는 투 포인터다연결 리스트 문제에서는 투 포인터가 또 다른 모습으로 나온다.대표 예시: 중간 노드 찾기 사이클 판별여기서는 보통: slow는 한 칸씩 fast는 두 칸씩움직인다.이것도 포인터 두 개를 움직이는 기법이므로 넓게 보면 투 포인터다.다만 배열 문제에서 말하는 투 포인터와는 목적이 다르므로,코테 알고리즘 노트에서는 보통 분리해서 이해해도 된다.16. 자주 하는 실수1) 슬라이딩 윈도우를 음수 배열에 그대로 적용가장 흔한 실수다.합의 단조성이 깨지면 논리가 무너진다.2) 양끝 투 포인터에서 정렬을 안 함정렬이 안 되어 있으면 포인터 이동 근거가 없다.3) while 조건을 잘못 둠예를 들어 left < right인지, left <= right인지에 따라 중복 세기나 종료 조건이 달라진다.4) 구간 길이 계산 실수end - start + 1를 자주 틀린다.5) 조건을 만족했을 때 어떤 포인터를 움직일지 애매하게 처리문제에 따라 다르다. 한 쌍만 찾는가 모든 쌍을 세는가 중복을 허용하는가를 먼저 분명히 해야 한다.17. 실전 판단 기준문제에서 아래 조건 조합이 보이면 투 포인터일 가능성이 높다.슬라이딩 윈도우 쪽 연속 부분 배열 합, 길이, 조건 만족 여부 원소가 양수 또는 비음수 최솟값/최댓값/개수양끝 투 포인터 쪽 정렬된 배열 두 수의 합 / 차이 쌍 찾기 양끝에서 줄여 나가기18. 시험장용 최소 암기 버전투 포인터:포인터 두 개를 움직여 중복 탐색 줄이기분류:1) 슬라이딩 윈도우 - 같은 방향 - 연속 구간2) 양끝 투 포인터 - 반대 방향 - 정렬된 배열의 쌍슬라이딩 윈도우 핵심:조건 안 되면 end++조건 되면 start++양끝 투 포인터 핵심:sum < target -> left++sum > target -> right--주의:음수 배열정렬 여부중복 처리19. 최종 요약투 포인터는 다음 문장으로 정리할 수 있다.두 개의 포인터를 단조롭게 움직이며구간이나 쌍을 선형 시간에 처리하는 기법핵심만 다시 압축하면: 슬라이딩 윈도우는 연속 구간 문제에 사용 양끝 투 포인터는 정렬된 배열의 쌍 문제에 사용 한 번 계산한 정보를 재활용해 중복 탐색을 줄인다 슬라이딩 윈도우는 보통 양수 배열에서 특히 강력하다 양끝 투 포인터는 정렬이 핵심 전제다문제를 보면 먼저 이 질문을 하면 된다.포인터를 한 방향 또는 양끝에서 움직이면이전 계산을 재활용할 수 있는가?이 질문의 답이 예라면 투 포인터일 가능성이 높다.
- Trie Algorithm algorithm trie Trie Trie트라이(Trie)는 문자열 집합을 문자 단위로 나눠 저장하는 트리 자료구조다.한 줄로 요약하면 다음과 같다.문자열의 공통 접두사를 공유해서 저장하는 자료구조즉 문자열을 통째로 저장하는 것이 아니라,앞 글자부터 경로처럼 저장한다.1. 언제 쓰는가아래 상황이면 트라이를 떠올릴 수 있다. 문자열 집합 저장 접두사 검색 자동 완성 사전 순 탐색 문자열 삽입 / 검색을 많이 반복 어떤 문자열이 다른 문자열의 접두사인지 판별대표 문제: 전화번호 목록 문자열 집합 자동 완성2. 왜 해시셋만으로는 부족한가해시셋은 특정 문자열이 존재하는지 찾는 데 강하다.하지만 접두사 검색은 직접적이지 않다.예: cat, car, care, dog이 있을 때,ca로 시작하는 문자열이 있는지 빠르게 확인하고 싶다면,트라이가 훨씬 자연스럽다.왜냐하면 c -> a 경로만 따라가 보면 되기 때문이다.3. 핵심 아이디어예를 들어 cat, car, dog를 저장하면,ca 부분은 같이 공유된다.즉 공통 접두사를 묶어서 저장하므로,문자열 검색이 길이에 비례해서 가능하다.flowchart TD R["루트"] --> C["c"] C --> A["a"] A --> T["t"] A --> R2["r"] R --> D["d"] D --> O["o"] O --> G["g"]이 다이어그램에서 cat과 car가 c -> a를 공유하는 것이 핵심이다.4. 기본 구조배열 기반 노드static class Node { Node[] child = new Node[26]; boolean end;} child[i]: 다음 문자로 가는 포인터 end: 이 노드에서 단어가 끝나는지 여부보통 소문자 영어만 다루면 길이 26 배열로 충분하다.문자 종류가 다양하면:Map<Character, Node> child = new HashMap<>();처럼 구현하기도 한다.5. end가 왜 필요한가예를 들어 car를 넣고 care도 넣었다고 하자.그러면 r 노드까지는 공통이다.이때 car 자체가 단어인지 알기 위해서는,문자 경로만으로는 부족하고 “여기서 끝나는 단어가 있는가” 표시가 필요하다.즉:경로 존재 여부 != 단어 존재 여부이 차이를 구분하기 위해 end가 필요하다.6. 삽입 InsertNode root = new Node();void insert(String s) { Node cur = root; for (char ch : s.toCharArray()) { int idx = ch - 'a'; if (cur.child[idx] == null) { cur.child[idx] = new Node(); } cur = cur.child[idx]; } cur.end = true;}의미: 글자를 하나씩 따라 내려가고 없으면 새 노드를 만들고 마지막 글자 노드에 end = true7. 검색 Searchboolean search(String s) { Node cur = root; for (char ch : s.toCharArray()) { int idx = ch - 'a'; if (cur.child[idx] == null) return false; cur = cur.child[idx]; } return cur.end;}중요한 점: 경로만 있다고 끝이 아니다 마지막에 cur.end가 true여야 실제 단어다예: care가 들어 있고 car는 안 들어 있으면c -> a -> r 경로는 존재해도 search("car")는 false일 수 있다.8. 접두사 확인 StartsWithboolean startsWith(String s) { Node cur = root; for (char ch : s.toCharArray()) { int idx = ch - 'a'; if (cur.child[idx] == null) return false; cur = cur.child[idx]; } return true;}이 함수는 단어의 끝 여부가 아니라,그 접두사를 가진 문자열이 존재하는지만 본다.9. 작은 예시로 따라가기문자열을 다음 순서로 넣는다고 하자.catcarcaredoginsert("cat") root -> c -> a -> t 생성 t.end = trueinsert("car") root -> c -> a 는 이미 있음 r만 새로 생성 r.end = trueinsert("care") root -> c -> a -> r 까지 이미 있음 e만 새로 생성 e.end = true이 과정을 보면 접두사가 공유되는 이유가 보인다.특히 car와 care를 같이 보면 end의 의미가 분명해진다. r.end = true면 car가 단어라는 뜻 e.end = true면 care도 단어라는 뜻즉 같은 경로를 공유하더라도 어디서 단어가 끝나는지는 별도로 기록해야 한다.완성된 트라이 구조cat, car, care, dog 네 단어를 모두 삽입한 뒤의 트라이:graph TD root(("루트")) root --> c((c)) root --> d((d)) c --> a((a)) a --> t(("t [끝]")) a --> r(("r [끝]")) r --> e(("e [끝]")) d --> o((o)) o --> g(("g [끝]"))[end] 표시가 있는 노드에서 단어가 끝난다.같은 접두사 ca를 cat, car, care가 공유하고 있는 것이 핵심이다.10. 시간 복잡도문자열 길이를 L이라 하면: 삽입: O(L) 검색: O(L) 접두사 확인: O(L)즉 데이터 개수가 많아도,한 번의 연산은 문자열 길이에 비례한다.이 점 때문에 문자열 개수가 매우 많아도,한 번의 삽입과 검색은 문자열 길이만큼만 보면 된다는 장점이 있다.11. 메모리 관점에서의 주의점트라이는 빠르지만 메모리를 많이 쓸 수 있다.특히: 문자 종류가 많고 문자열 개수가 많고 배열 기반 자식 포인터를 쓰면빈 칸이 많이 생길 수 있다.그래서 문자 종류가 많은 경우는 HashMap<Character, Node> 기반이 더 유리할 수 있다.반대로 문자 종류가 적고 고정되어 있으면 배열 기반이 더 빠르고 단순하다.즉 트라이는: 속도를 우선하면 배열 기반 메모리를 아끼고 문자 종류가 다양하면 맵 기반으로 생각하면 된다.삭제나 prefix 개수까지 필요하면 pass를 둔다실전에서는 단순 search만이 아니라: 어떤 접두사를 가진 단어 수 문자열 삭제 같은 단어가 몇 번 들어왔는지까지 묻는 경우가 많다.이때는 각 노드에 pass와 endCount를 두면 편하다.static class Node { Map<Character, Node> child = new HashMap<>(); int pass; int endCount;} pass: 이 노드를 지나는 단어 수 endCount: 이 노드에서 끝나는 단어 수삽입할 때는 경로를 따라 pass++,삭제할 때는 pass-- 하면서 내려가고,pass == 0인 노드는 정리할 수 있다.이 패턴을 알고 있으면전화번호 목록, 자동완성, 문자열 멀티셋 문제까지 같은 구조로 확장할 수 있다.12. 자주 하는 실수1) end 표시를 안 함단어 존재와 접두사 존재를 구분 못 하게 된다.2) 문자 인덱스 변환 실수int idx = ch - 'a';를 정확히 맞춰야 한다.3) 문자 종류가 많은데 배열만 고집함메모리가 과하게 커질 수 있다.4) 루트 처리 혼동루트는 보통 빈 문자열 시작점이다.문자를 직접 들고 있지 않아도 된다.13. 시험장용 최소 암기 버전Trie:문자열 접두사 공유 저장핵심:insert / search / startsWith구조:child[] or Mapend flag복잡도:문자열 길이 L에 대해 O(L)14. 최종 요약트라이는 다음 문장으로 정리할 수 있다.문자열을 접두사 단위로 공유 저장해서삽입과 검색을 문자열 길이만큼 처리하는 자료구조문제를 보면 먼저 이 질문을 하면 된다.문자열 전체 일치만 필요한가,아니면 접두사 구조 자체가 중요한가?접두사가 핵심이면 트라이 가능성이 높다.
- Tree Algorithm algorithm tree Tree Tree트리(Tree)는 사이클이 없는 연결 그래프다.한 줄로 요약하면 다음과 같다.노드들이 계층 구조를 이루며 연결된 그래프코테에서 트리는 매우 자주 나오고,그래프 문제 중에서도 성질이 특별해서 더 쉽게 다룰 수 있는 경우가 많다.1. 트리의 가장 중요한 성질정점 수가 N인 트리는 다음을 만족한다. 간선 수는 항상 N - 1 연결되어 있다 사이클이 없다 임의의 두 정점 사이의 경로가 정확히 하나다이 마지막 성질이 매우 중요하다.두 정점 사이 경로가 하나뿐이다그래서 일반 그래프보다 훨씬 단순한 논리로 많은 문제를 풀 수 있다.2. 핵심 용어아래 트리를 보자. 1 / \ 2 3 / \ \ 4 5 6용어 정리: 1: 루트(root) 2, 3: 1의 자식(children) 1: 2와 3의 부모(parent) 2, 3: 서로 형제(sibling) 4, 5, 6: 리프(leaf), 즉 자식이 없는 노드 서브트리(subtree): 어떤 노드를 루트로 하는 부분 트리추가로 꼭 알아야 하는 개념: 깊이(depth): 루트에서 현재 노드까지의 거리 높이(height): 현재 노드에서 가장 먼 리프까지의 거리 레벨(level): 깊이와 거의 같은 의미로 쓰이는 경우가 많음보통 코테에서는: 루트 깊이 = 0으로 두는 경우가 많다.3. 왜 트리가 특별한가일반 그래프에서는 같은 정점으로 가는 경로가 여러 개일 수 있다.그래서 방문 처리, 사이클 처리, 최단 거리, 중복 경로를 모두 고려해야 한다.하지만 트리는: 경로가 하나뿐이고 사이클이 없고 부모 하나만 조심하면 다시 되돌아가지 않는다그래서 DFS나 BFS가 매우 깔끔해진다.특히 트리 문제에서 자주 쓰는 패턴은 다음이다.dfs(node, parent)즉 현재 노드와 부모만 들고 다니면 역방향 방문을 쉽게 막을 수 있다.flowchart TD A["루트"] --> B["자식 1"] A --> C["자식 2"] B --> D["B의 서브트리"] C --> E["C의 서브트리"]트리 문제는 이렇게 루트에서 자식 방향으로 내려가며 각 서브트리를 따로 계산한다고 생각하면 훨씬 이해가 쉽다.4. 트리의 표현 방법트리는 상황에 따라 두 가지 방식으로 많이 표현한다.1) 이진 트리 노드 클래스LeetCode 스타일 문제에서 자주 보인다.class TreeNode { int val; TreeNode left; TreeNode right; TreeNode(int val) { this.val = val; }}이 방식은: 이진 트리 문제 BST 문제 재귀 순회 문제에서 자주 등장한다.2) 인접 리스트백준, 프로그래머스, 일반 코테에서는 보통 이쪽이 더 중요하다.ArrayList<Integer>[] tree = new ArrayList[n + 1];for (int i = 1; i <= n; i++) { tree[i] = new ArrayList<>();}for (int i = 0; i < n - 1; i++) { int u = ...; int v = ...; tree[u].add(v); tree[v].add(u);}트리 입력은 보통 무방향 간선 N - 1개로 들어온다.그래서 부모-자식 관계는 DFS/BFS를 돌려 직접 정해야 한다.무방향 트리를 루트 트리로 바꾸기백준 스타일 입력은 대개 “그냥 트리”만 주고루트는 따로 정해 주지 않거나, 문제에서 임의의 루트를 지정해 준다.이때 가장 먼저 해야 할 일은:루트를 하나 정하고부모 / 깊이 / 자식 관계를 만든다는 것이다.예를 들어 1번을 루트로 잡으면:int[] parent = new int[n + 1];int[] depth = new int[n + 1];void dfs(int cur, int par) { parent[cur] = par; for (int next : tree[cur]) { if (next == par) continue; depth[next] = depth[cur] + 1; dfs(next, cur); }}이렇게 한 번 돌고 나면: parent[x]: 부모 depth[x]: 깊이 tree[x]에서 parent[x]를 제외한 나머지: 자식 방향으로 해석할 수 있다.여기서 중요한 점은 입력 트리 자체에는 부모/자식 방향이 없고,루트를 정한 뒤에야 그런 개념이 생긴다는 것이다.그래서: LCA 서브트리 크기 트리 DP Euler Tour같은 문제는 거의 항상 “먼저 루팅부터”라고 생각하면 된다.5. 트리 순회1) 이진 트리 순회전위 순회 Preorder루트 -> 왼쪽 -> 오른쪽void preorder(TreeNode node) { if (node == null) return; System.out.print(node.val + " "); preorder(node.left); preorder(node.right);}루트를 먼저 처리해야 할 때 쓴다.중위 순회 Inorder왼쪽 -> 루트 -> 오른쪽void inorder(TreeNode node) { if (node == null) return; inorder(node.left); System.out.print(node.val + " "); inorder(node.right);}BST에서는 중위 순회 결과가 정렬 순서가 된다.순회 비교 예시아래 이진 트리로 세 가지 순회를 비교하면 확실히 이해된다.graph TD A((1)) --> B((2)) A --> C((3)) B --> D((4)) B --> E((5)) C --> F((6)) C --> G((7)) style A fill:#ff9999 style B fill:#99ccff style C fill:#99ccff style D fill:#ccffcc style E fill:#ccffcc style F fill:#ccffcc style G fill:#ccffccPreorder (루트→왼→오): 1 2 4 5 3 6 7Inorder (왼→루트→오): 4 2 5 1 6 3 7Postorder (왼→오→루트): 4 5 2 6 7 3 1Level (층별 왼→오) : 1 2 3 4 5 6 7후위 순회 Postorder왼쪽 -> 오른쪽 -> 루트void postorder(TreeNode node) { if (node == null) return; postorder(node.left); postorder(node.right); System.out.print(node.val + " ");}자식 결과를 먼저 계산하고 부모에서 합칠 때 핵심이다.트리 DP의 기본 순서가 사실상 후위 순회다.레벨 순회 Level Order위에서 아래로, 깊이 순서대로 순회void levelOrder(TreeNode root) { if (root == null) return; Queue<TreeNode> q = new LinkedList<>(); q.offer(root); while (!q.isEmpty()) { TreeNode cur = q.poll(); System.out.print(cur.val + " "); if (cur.left != null) q.offer(cur.left); if (cur.right != null) q.offer(cur.right); }}6. 일반 트리에서 가장 중요한 DFS 패턴코테에서 트리 문제는 대부분 일반 트리 + 인접 리스트 형태다.이때 가장 중요한 패턴은 다음이다.void dfs(int node, int parent) { for (int next : tree[node]) { if (next == parent) continue; dfs(next, node); }}왜 parent를 들고 다니는가?트리는 무방향 그래프로 입력되므로,현재 노드에서 인접 정점을 보면 부모도 같이 들어 있다.따라서 부모를 건너뛰지 않으면: 자식으로 갔다가 다시 부모로 돌아오고 무한 재귀가 발생한다즉 parent 체크는 트리 DFS의 핵심이다.7. 부모와 깊이 구하기가장 기본적인 트리 전처리다.int[] parent;int[] depth;void dfs(int node, int par, int dep) { parent[node] = par; depth[node] = dep; for (int next : tree[node]) { if (next == par) continue; dfs(next, node, dep + 1); }}이렇게 해 두면 다음이 가능하다. 각 노드의 부모 확인 루트로부터의 깊이 계산 LCA 준비 거리 계산8. 서브트리 크기 구하기트리 문제에서 가장 자주 나오는 기본 DP다.정의:size[node] = node를 루트로 하는 서브트리의 노드 수점화:size[node] = 1 + 모든 자식 size의 합int[] size;void calcSize(int node, int parent) { size[node] = 1; for (int next : tree[node]) { if (next == parent) continue; calcSize(next, node); size[node] += size[next]; }}왜 후위 순회인가?자식들의 size를 먼저 알아야 부모 size를 계산할 수 있기 때문이다.9. 높이와 가장 깊은 자손 구하기트리의 높이도 매우 자주 나온다.정의:height[node] = node에서 가장 먼 리프까지의 거리int height(int node, int parent) { int h = 0; for (int next : tree[node]) { if (next == parent) continue; h = Math.max(h, height(next, node) + 1); } return h;}이 역시 자식 결과를 부모에서 합치므로 후위 순회 구조다.10. 트리의 지름트리의 지름은:트리에서 가장 멀리 떨어진 두 노드 사이의 거리이다.대표 풀이가 두 가지 있다.방법 1. DFS/BFS 두 번 임의의 노드에서 가장 먼 노드 A를 찾는다. A에서 가장 먼 노드 B를 찾는다. A-B 거리가 지름이다.이 방법은 구현이 단순하고 자주 쓰인다.방법 2. 한 번의 DFS로 계산각 노드에서: 자식 방향 최대 깊이 두 개를 구한 뒤 그 합의 최댓값을 전체 지름으로 관리한다int diameter = 0;int dfs(int node, int parent) { int first = 0; int second = 0; for (int next : tree[node]) { if (next == parent) continue; int d = dfs(next, node) + 1; if (d > first) { second = first; first = d; } else if (d > second) { second = d; } } diameter = Math.max(diameter, first + second); return first;}핵심은 어떤 노드를 꼭대기로 하는 최장 경로는그 노드의 자식 방향 최대 깊이 두 개를 합친 것이라는 점이다.graph TD A((1)) --> B((2)) A --> C((3)) B --> D((4)) B --> E((5)) D --> H((8)) linkStyle 0 stroke:#ff0000,stroke-width:3 linkStyle 2 stroke:#ff0000,stroke-width:3 linkStyle 4 stroke:#ff0000,stroke-width:3 linkStyle 1 stroke:#0066ff,stroke-width:3 style H fill:#ffcc00 style C fill:#ffcc00노드 2에서: first = depth(8) = 2, second = depth(5) = 1→ first + second = 3하지만 노드 1에서: first = depth(8 via 2) = 3, second = depth(3) = 1→ first + second = 4 ← 이것이 지름지름 경로: 8 → 4 → 2 → 1 → 3 (거리 4)11. LCA 최소 공통 조상LCA(Lowest Common Ancestor)는 두 노드의 가장 가까운 공통 조상이다.예를 들어: 4와 5의 LCA는 2 4와 6의 LCA는 1트리 쿼리 문제에서 매우 자주 나온다.graph TD N1((1)) --> N2((2)) N1 --> N3((3)) N2 --> N4((4)) N2 --> N5((5)) N3 --> N6((6)) style N2 fill:#ffcc00,stroke:#333,stroke-width:3 style N4 fill:#99ccff style N5 fill:#99ccff style N1 fill:#ffcc00,stroke:#333,stroke-width:3 style N6 fill:#ff9999LCA(4, 5) = 2 ← 같은 부모LCA(4, 6) = 1 ← 루트까지 올라가야 만남LCA(4, 2) = 2 ← 한쪽이 조상이면 그 자체가 LCA12. LCA 기본 풀이: 깊이 맞추고 같이 올리기먼저 각 노드의 부모와 깊이를 알고 있다고 하자.그럼 방법은 다음과 같다. 더 깊은 노드를 위로 올려 깊이를 맞춘다. 두 노드가 같아질 때까지 같이 올린다. 처음 만난 노드가 LCA다.int lca(int u, int v) { while (depth[u] > depth[v]) u = parent[u]; while (depth[v] > depth[u]) v = parent[v]; while (u != v) { u = parent[u]; v = parent[v]; } return u;}이 방식은 직관적이지만,쿼리가 많으면 느릴 수 있다.13. LCA 고급 풀이: Binary Lifting쿼리가 많다면 이진 리프팅을 쓴다.아이디어:2^k번째 조상을 미리 저장해 두자즉 up[k][v]를 다음처럼 정의한다.v의 2^k번째 조상그러면 깊이 차이를 빠르게 줄이고,두 노드를 O(log N)에 동시에 올릴 수 있다.graph TD R((Root)) --> A((A)) A --> B((B)) B --> C((C)) C --> D((D)) D --> E((E)) E --> F((F)) F --> G((v)) G -. "up[0] = 2⁰ = 1칸" .-> F G -. "up[1] = 2¹ = 2칸" .-> E G -. "up[2] = 2² = 4칸" .-> C G -. "up[3] = 2³ = 8칸 (코드에 따라 0 또는 root)" .-> R style G fill:#99ccff,stroke:#333,stroke-width:3깊이 차이가 5면 5 = 4 + 1 = 2² + 2⁰이므로 점프 2번이면 된다.실전에서는 루트의 부모를 0으로 두는 방식이 흔하다.즉 위 코드의 up[k][0]은 계속 0으로 남는 sentinel이라고 이해하면 된다.전처리int LOG = 20;int[][] up;void build(int n) { up = new int[LOG][n + 1]; for (int v = 1; v <= n; v++) { up[0][v] = parent[v]; } for (int k = 1; k < LOG; k++) { for (int v = 1; v <= n; v++) { up[k][v] = up[k - 1][up[k - 1][v]]; } }}쿼리int lca(int u, int v) { if (depth[u] < depth[v]) { int tmp = u; u = v; v = tmp; } int diff = depth[u] - depth[v]; for (int k = 0; k < LOG; k++) { if (((diff >> k) & 1) == 1) { u = up[k][u]; } } if (u == v) return u; for (int k = LOG - 1; k >= 0; k--) { if (up[k][u] != up[k][v]) { u = up[k][u]; v = up[k][v]; } } return up[0][u];}14. 트리 DP의 본질트리 DP는 트리 문제의 핵심 파트다.사고 순서는 늘 비슷하다.1. dp[node]가 무엇을 의미하는가?2. 자식들의 dp로 부모 dp를 어떻게 만들 것인가?3. 계산 순서는 자식 먼저인가, 부모 먼저인가?대부분의 트리 DP는 후위 순회다.왜냐하면 부모 값이 자식 값들에 의존하는 경우가 많기 때문이다.15. 트리 DP 예제 1: 서브트리 합정의:dp[node] = node를 루트로 하는 서브트리의 값 합점화:dp[node] = value[node] + 자식들의 dp 합int[] value;long[] dp;void dfs(int node, int parent) { dp[node] = value[node]; for (int next : tree[node]) { if (next == parent) continue; dfs(next, node); dp[node] += dp[next]; }}이 문제는 트리 DP의 가장 기본 형태다.16. 트리 DP 예제 2: 선택/비선택 DP대표 문제:인접한 노드를 동시에 선택할 수 없을 때가치 합의 최댓값을 구하라정의: dp[node][0]: 현재 노드를 선택하지 않았을 때 최댓값 dp[node][1]: 현재 노드를 선택했을 때 최댓값점화: 내가 선택되지 않으면 자식은 선택해도 되고 안 해도 된다 내가 선택되면 자식은 선택되면 안 된다int[] value;long[][] dp;void dfs(int node, int parent) { dp[node][0] = 0; dp[node][1] = value[node]; for (int next : tree[node]) { if (next == parent) continue; dfs(next, node); dp[node][0] += Math.max(dp[next][0], dp[next][1]); dp[node][1] += dp[next][0]; }}답:Math.max(dp[root][0], dp[root][1])이 패턴은 매우 중요하다.17. 리루팅 Rerooting어떤 문제는 루트를 1개 정해서 계산한 결과만으로는 부족하다.예:모든 노드에 대해자기에서 가장 먼 노드까지의 거리를 구하라이런 문제는 각 노드를 루트로 본 결과가 필요하므로,한 번의 단순 DFS만으로는 안 된다.이때 쓰는 기법이 리루팅이다.핵심 아이디어: 한 번은 아래 방향 정보 계산 한 번은 위 방향 정보 전달즉 루트를 바꿔 가는 효과를 선형 시간 안에 만든다.구체적인 과정은 다음과 같다.1단계 (Down-pass): 루트에서 DFS로 자식 방향 정보 계산 → dp_down[v] = v의 서브트리 기준 결과2단계 (Up-pass): 루트에서 다시 DFS로 부모 방향 정보 전달 → dp_up[v] = v를 제외한 나머지 트리 기준 결과3단계: 문제별 결합 규칙으로 정답 계산 → ans[v] = combine(dp_down[v], dp_up[v], 형제 정보, 부모 정보 ...)리루팅은 트리 DP의 확장형으로 이해하면 된다.중요한 점은 ans[v] = dp_down[v] + dp_up[v]처럼 항상 단순 합으로 끝나는 것은 아니라는 점이다.문제에 따라 최대값을 취할 수도 있고, 부모 쪽 최적값과 형제 서브트리 정보를 다시 조합해야 할 수도 있다.18. 오일러 투어 Euler Tour서브트리 문제를 배열 문제로 바꾸는 기법이다.DFS 순서로 트리를 펼치면,한 노드의 서브트리가 배열의 연속 구간이 된다.int[] in;int[] out;int timer = 0;void euler(int node, int parent) { in[node] = ++timer; for (int next : tree[node]) { if (next == parent) continue; euler(next, node); } out[node] = timer;}그러면:node의 서브트리 = [in[node], out[node]]가 된다.이 위에 세그먼트 트리, 펜윅 트리, 누적합 등을 얹으면 서브트리 쿼리를 빠르게 처리할 수 있다.19. BFS도 트리에서 자주 쓴다트리는 DFS가 대표적이지만 BFS도 자주 쓰인다.예: 레벨별 탐색 최단 거리 계산 부모/깊이 구하기 트리의 지름을 구하기 위한 BFSvoid bfs(int start, int n) { Queue<Integer> q = new LinkedList<>(); boolean[] visited = new boolean[n + 1]; q.offer(start); visited[start] = true; while (!q.isEmpty()) { int cur = q.poll(); for (int next : tree[cur]) { if (visited[next]) continue; visited[next] = true; q.offer(next); } }}트리는 간선 가중치가 없으면 BFS 한 번으로 깊이와 거리를 깔끔하게 계산할 수 있다.20. 자주 하는 실수1) 부모 체크를 안 함트리 입력은 보통 무방향 그래프다.parent 체크나 visited 체크 없이 DFS를 돌리면 무한 루프가 난다.2) 루트 개념을 혼동함입력 자체는 방향이 없는 경우가 많다.루트는 DFS/BFS를 돌리기 위해 우리가 정하는 경우가 많다.3) 깊이와 높이를 혼동함 깊이: 루트에서 현재 노드까지 높이: 현재 노드에서 가장 먼 리프까지4) 트리 DP에서 전이보다 먼저 순회를 돌림먼저 dp[node] 정의를 정해야 한다.정의 없이 코드를 쓰면 거의 꼬인다.5) 재귀 깊이 문제를 무시함Java에서 노드 수가 매우 크면 재귀가 위험할 수 있다.이 경우: 스택 크기 이슈를 확인하거나 반복 DFS/BFS로 바꾸는 판단이 필요하다6) LCA에서 깊이 맞추기 전에 바로 동시에 올림깊이가 다르면 먼저 깊이를 맞춰야 한다.21. 실전 판단 기준문제에서 아래 키워드가 보이면 트리 기본기를 떠올리면 된다. 부모, 자식, 루트 서브트리 조상, 공통 조상 리프 트리의 지름 모든 노드에 대한 값 계산 트리 DP그리고 문제를 보면 먼저 다음을 확인하면 된다. 입력이 인접 리스트형 트리인가? 루트가 주어졌는가? 서브트리 정보를 구하는가? 두 노드 관계를 구하는가? 모든 노드에 대한 답이 필요한가?이 질문에 따라: 서브트리 -> 후위 DFS, 트리 DP 두 노드 관계 -> 부모/깊이/LCA 모든 노드 답 -> 리루팅 서브트리 쿼리 -> 오일러 투어로 이어진다.22. 시험장용 최소 암기 버전트리:사이클 없는 연결 그래프간선 수 = N - 1경로 유일기본 패턴:dfs(node, parent)자주 구하는 것:parentdepthsubtree sizeheightdiameterLCA트리 DP 핵심:dp[node] 정의자식 -> 부모로 전이대부분 후위 순회고급:LCA binary liftingrerootingeuler tour23. 최종 요약트리는 다음 문장으로 정리할 수 있다.사이클이 없고 경로가 유일한 그래프라서부모-자식 구조를 이용한 DFS, DP, 쿼리가 매우 잘 맞는 자료 구조핵심만 다시 압축하면: 트리는 N - 1개의 간선을 가진 연결 그래프 일반 트리에서는 dfs(node, parent) 패턴이 핵심 서브트리 계산은 후위 순회가 기본 두 노드 관계는 깊이와 LCA가 중요 모든 노드 답이 필요하면 리루팅을 생각 서브트리 쿼리는 오일러 투어로 배열화 가능트리 문제를 보면 항상 먼저 이 질문을 하면 된다.이 문제는서브트리 문제인가,두 노드 문제인가,아니면 모든 노드 문제인가?이 구분이 잡히면 풀이 방향이 훨씬 빨리 정해진다.
- Topological Sort Algorithm algorithm topological-sort Topological Sort Topological Sort위상 정렬(Topological Sort)은 방향 그래프에서 선후 관계를 지키며 정점을 나열하는 알고리즘이다.한 줄로 요약하면 다음과 같다.앞서야 하는 정점을 먼저 배치하는 정렬즉 숫자를 오름차순으로 정렬하는 것이 아니라,A를 먼저 해야 B를 할 수 있다같은 제약을 만족하는 순서를 만드는 것이다.1. 언제 쓰는가문제에서 아래 표현이 보이면 위상 정렬을 떠올리면 된다. 선수 과목 작업 순서 빌드 순서 먼저 해야 하는 일 순서 제약 A를 한 뒤에야 B 가능대표 문제 상황: 과목 수강 순서 공정 작업 순서 선행 관계 정렬2. 전제 조건: DAG위상 정렬은 DAG(Directed Acyclic Graph) 에서만 가능하다.즉: 방향 그래프여야 하고 사이클이 없어야 한다예를 들어:A -> B -> C -> A같은 사이클이 있으면,A보다 먼저 B가 와야 하고,B보다 먼저 C가 와야 하고,C보다 먼저 A가 와야 하므로 순서를 만들 수 없다.3. 핵심 개념: indegreeKahn 알고리즘에서 가장 중요한 것은 indegree다.indegree[v] = v로 들어오는 간선 수즉 아직 끝나지 않은 선행 작업 개수라고 생각해도 된다. indegree가 0이면 지금 바로 시작 가능 indegree가 1 이상이면 아직 기다려야 함이 해석이 핵심이다.4. Kahn 알고리즘 핵심 흐름 indegree가 0인 정점을 모두 큐에 넣는다 큐에서 하나 꺼내 정답에 넣는다 그 정점에서 나가는 간선을 제거한 효과로 이웃 indegree를 줄인다 새로 indegree가 0이 된 정점을 큐에 넣는다 큐가 빌 때까지 반복한다flowchart TD A["Indegree 0인 노드 큐에 삽입"] --> B["큐에서 추출"] B --> C["답에 추가"] C --> D["인접 노드 Indegree 감소"] D --> E{"Indegree가 0이 됨?"} E -->|Yes| F["새 노드 큐에 삽입"] F --> G{"큐가 비었는가?"} E -->|No| G G -->|No| B G -->|Yes| H["완료"]즉 “지금 당장 할 수 있는 일”을 하나씩 꺼내는 구조다.5. 작은 예시로 이해하기그래프:1 -> 32 -> 33 -> 4graph LR N1((1)) --> N3((3)) N2((2)) --> N3 N3 --> N4((4)) style N1 fill:#99ccff style N2 fill:#99ccff style N3 fill:#ffcc99 style N4 fill:#ccffcc파란색 노드 1, 2는 indegree가 0이므로 처음에 큐에 들어간다.주황색 3은 indegree가 2이므로 선행 노드가 모두 처리된 후에야 큐에 들어간다.초기 indegree: 1: 0 2: 0 3: 2 4: 1초기 큐:[1, 2]1단계1을 꺼낸다.정답: [1]1에서 3으로 가는 간선을 제거한 효과: indegree[3] = 1아직 0이 아니므로 큐에 안 들어간다.2단계2를 꺼낸다.정답: [1, 2]2에서 3으로 가는 간선을 제거한 효과: indegree[3] = 0이제 3을 큐에 넣는다.3단계3을 꺼낸다.정답: [1, 2, 3]3에서 4로 가는 간선을 제거: indegree[4] = 04를 큐에 넣는다.4단계4를 꺼낸다.정답: [1, 2, 3, 4]즉 선행 관계가 지켜진 순서를 얻는다.6. Kahn’s Algorithm 구현List<Integer> topoSort(int n, ArrayList<Integer>[] graph, int[] indegree) { Queue<Integer> q = new LinkedList<>(); List<Integer> order = new ArrayList<>(); for (int i = 1; i <= n; i++) { if (indegree[i] == 0) q.offer(i); } while (!q.isEmpty()) { int cur = q.poll(); order.add(cur); for (int next : graph[cur]) { indegree[next]--; if (indegree[next] == 0) { q.offer(next); } } } return order;}주의:이 구현은 indegree 배열을 직접 감소시키므로,원본 진입 차수가 나중에도 필요하면 복사본을 만들어 써야 한다.7. 사이클 판별위상 정렬 결과 길이가 N보다 작으면 사이클이 있다는 뜻이다.왜냐하면 사이클 안의 정점들은 indegree가 끝까지 0이 되지 못하기 때문이다.즉 다음으로 판별 가능하다.if (order.size() != n) { // cycle exists}8. 위상 정렬 결과는 하나가 아닐 수 있다예를 들어 indegree가 0인 정점이 여러 개면,그중 어떤 것을 먼저 꺼내느냐에 따라 결과가 달라질 수 있다.즉 위상 정렬은 보통:유일한 순서가 아니라 가능한 순서 중 하나를 구하는 알고리즘이다.만약 사전순으로 가장 작은 결과가 필요하면,큐 대신 우선순위 큐를 쓰면 된다.예를 들어 indegree가 0인 정점이 2, 5, 7 세 개라면,일반 큐는 입력이나 삽입 순서에 따라 결과가 달라질 수 있다.반면 우선순위 큐를 쓰면 항상 가장 작은 정점부터 꺼내므로,“가능한 위상 정렬 중 사전순 최소”를 만들 수 있다.9. 우선순위 큐 버전PriorityQueue<Integer> pq = new PriorityQueue<>();를 써서 indegree 0 정점 중 가장 작은 번호를 먼저 꺼내면,사전순으로 가장 앞선 위상 정렬 결과를 만들 수 있다.이 패턴은 BOJ 문제에서 자주 나온다.10. DFS 방식도 있다위상 정렬은 DFS 후위 순회 기반으로도 가능하다.핵심은: 자식들을 먼저 방문 현재 노드를 스택이나 리스트 뒤에 넣음이 방식은 재귀적이라 직관적일 수 있다.하지만 코테에서는 Kahn 알고리즘이 더 명확하고,indegree 기반 해석이 쉬워서 더 많이 쓴다.실전에서는: 순서를 실제로 만들고 싶다 -> Kahn DFS 기반 사이클 판별과 같이 묶고 싶다 -> DFS 방식처럼 구분해도 된다.DFS 후위 순회void dfs(int cur, ArrayList<Integer>[] graph, boolean[] visited, List<Integer> order) { visited[cur] = true; for (int next : graph[cur]) { if (!visited[next]) dfs(next, graph, visited, order); } order.add(cur);}모든 정점에 대해 DFS를 돌고 마지막에 order를 뒤집으면 위상 정렬 순서가 된다.다만 방향 그래프에서 사이클 여부까지 엄밀히 보려면visited만으로는 부족하고 inStack 또는 3색 방문 배열이 추가로 필요하다.순서가 유일한지 판단하는 법Kahn 알고리즘에서는 어떤 시점이든 큐에 indegree 0 정점이 두 개 이상 있으면,그 순간 서로 다른 선택지가 존재하므로 결과가 유일하지 않다.즉: 큐 크기가 항상 1이었다 -> 현재 그래프에서는 순서가 유일 어느 순간 큐 크기가 2 이상이었다 -> 가능한 위상 정렬이 여러 개문제에서 “순서가 하나로 정해지는가”를 묻는 경우이 조건을 그대로 써먹을 수 있다.11. 자주 하는 실수1) 무방향 그래프에 위상 정렬 적용위상 정렬은 방향 그래프 전용이다.2) indegree 초기화 실수간선 u -> v가 있으면 indegree[v]++다.3) 사이클 검사를 안 함결과 크기가 N인지 확인해야 한다.4) 큐에 처음 indegree 0 정점을 다 안 넣음시작이 잘못되면 전체 순서가 틀린다.12. 시험장용 최소 암기 버전위상 정렬:선후 관계를 만족하는 순서 만들기전제:DAGKahn:indegree 0을 큐에 넣고 시작꺼내면서 이웃 indegree 감소새로 0 되면 큐 삽입사이클 판별:결과 길이 < N13. 최종 요약위상 정렬은 다음 문장으로 정리할 수 있다.사이클 없는 방향 그래프에서선후 관계를 지키는 순서를 만드는 알고리즘문제를 보면 먼저 이 질문을 하면 된다.이 문제는무엇을 먼저 해야 무엇을 나중에 할 수 있는가?이 관계가 핵심이면 위상 정렬일 가능성이 높다.
- String Algorithm algorithm string String String문자열(String)은 코테에서 가장 자주 나오는 데이터 형태 중 하나다.한 줄로 요약하면 다음과 같다.문자의 배열을 다루는 다양한 기법KMP 같은 고급 알고리즘은 별도 문서에서 다루고,여기서는 코테에서 자주 필요한 문자열 기본기와 패턴을 정리한다.1. Java 문자열 기본String은 불변이다String s = "hello";s.charAt(0); // 'h's.length(); // 5s.substring(1, 3); // "el"String은 한 번 만들어지면 내용을 바꿀 수 없다.문자열을 이어 붙일 때마다 새 객체가 생긴다.StringBuilder는 가변이다StringBuilder sb = new StringBuilder();sb.append("hello");sb.append(" world");sb.reverse();sb.toString(); // "dlrow olleh"문자열을 반복적으로 조작할 때는 반드시 StringBuilder를 쓴다.String 연결을 루프 안에서 반복하면 O(N²)이 된다.2. 왜 StringBuilder가 중요한가// 느림: O(N²)String result = "";for (int i = 0; i < n; i++) { result += arr[i];}// 빠름: O(N)StringBuilder sb = new StringBuilder();for (int i = 0; i < n; i++) { sb.append(arr[i]);}String result = sb.toString();코테에서는 N이 10만 이상이면 이 차이가 시간 초과를 만든다.3. 문자열 비교equals를 써야 한다String a = "hello";String b = "hello";a == b; // 참일 수도, 아닐 수도 (참조 비교)a.equals(b); // 항상 정확 (내용 비교)==는 참조를 비교하므로 내용 비교에는 반드시 equals를 쓴다.compareTo: 사전순 비교"abc".compareTo("abd"); // 음수 (abc < abd)"abd".compareTo("abc"); // 양수 (abd > abc)"abc".compareTo("abc"); // 04. 자주 쓰는 메서드 정리s.charAt(i) // i번째 문자s.length() // 길이s.substring(i, j) // i부터 j-1까지s.indexOf("ab") // "ab"가 처음 나타나는 위치 (-1이면 없음)s.contains("ab") // 포함 여부s.startsWith("he") // 접두사 확인s.endsWith("lo") // 접미사 확인s.toCharArray() // char 배열로 변환s.split(",") // 구분자로 분할s.trim() // 앞뒤 공백 제거s.toLowerCase() // 소문자로s.toUpperCase() // 대문자로s.replace("a", "b") // 모든 "a"를 "b"로String.valueOf(123) // 숫자 -> 문자열Integer.parseInt("123") // 문자열 -> 숫자5. char 다루기문자 종류 판별Character.isDigit(c) // 숫자?Character.isLetter(c) // 문자?Character.isLetterOrDigit(c)Character.isUpperCase(c)Character.isLowerCase(c)Character.toLowerCase(c)Character.toUpperCase(c)문자 -> 숫자 변환int digit = c - '0'; // '3' -> 3int index = c - 'a'; // 'c' -> 2char ch = (char)('a' + 2); // 2 -> 'c'이 변환은 빈도 배열을 만들 때 핵심이다.int[] count = new int[26];for (char c : s.toCharArray()) { count[c - 'a']++;}6. 팰린드롬 판별앞에서 읽으나 뒤에서 읽으나 같은 문자열boolean isPalindrome(String s) { int left = 0; int right = s.length() - 1; while (left < right) { if (s.charAt(left) != s.charAt(right)) return false; left++; right--; } return true;}투 포인터로 양 끝을 비교하면 O(N)에 판별할 수 있다.7. 문자열 뒤집기StringBuilder 활용String reversed = new StringBuilder(s).reverse().toString();직접 구현char[] arr = s.toCharArray();int left = 0, right = arr.length - 1;while (left < right) { char tmp = arr[left]; arr[left] = arr[right]; arr[right] = tmp; left++; right--;}String reversed = new String(arr);8. 부분 문자열 탐색단순 탐색int idx = s.indexOf("pattern");indexOf는 내부적으로 O(N * M) 시간이 걸릴 수 있다.모든 등장 위치 찾기List<Integer> positions = new ArrayList<>();int idx = 0;while ((idx = s.indexOf(pattern, idx)) != -1) { positions.add(idx); idx++;}패턴 길이가 길거나 텍스트가 크면 KMP나 해싱을 고려한다.9. 문자열 파싱코테에서 자주 나오는 파싱 패턴들이다.split으로 분할String[] tokens = "2026-03-07".split("-");// ["2026", "03", "07"]정규식 splitString[] words = "hello world".split("\\s+");// ["hello", "world"]한 글자씩 처리for (char c : s.toCharArray()) { if (c == '(') stack.push(c); else if (c == ')') stack.pop();}10. 숫자-문자열 변환숫자 → 문자열String s = String.valueOf(123);String s = Integer.toString(123);String s = "" + 123; // 간단하지만 느림문자열 → 숫자int n = Integer.parseInt("123");long n = Long.parseLong("123456789");진법 변환String binary = Integer.toBinaryString(10); // "1010"String hex = Integer.toHexString(255); // "ff"int n = Integer.parseInt("1010", 2); // 1011. 문자열 정렬flowchart TD A["문자열 정렬 문제"] --> B{"기준이 무엇인가?"} B -->|"사전순"| C["compareTo"] B -->|"길이순"| D["길이 비교 후 사전순"] B -->|"커스텀"| E["Comparator 직접 정의"]사전순 정렬String[] arr = {"banana", "apple", "cherry"};Arrays.sort(arr);// ["apple", "banana", "cherry"]길이 기준, 같으면 사전순Arrays.sort(arr, (a, b) -> { if (a.length() != b.length()) return a.length() - b.length(); return a.compareTo(b);});숫자 문자열을 크기 순으로문자열 "9", "30", "34", "5", "3"을 조합해서 가장 큰 수를 만드는 문제:Arrays.sort(arr, (a, b) -> (b + a).compareTo(a + b));이 트릭은 프로그래머스 “가장 큰 수” 문제의 핵심이다.12. 괄호 문자열스택 + 문자열의 대표 조합이다.boolean isValid(String s) { Deque<Character> stack = new ArrayDeque<>(); for (char c : s.toCharArray()) { if (c == '(' || c == '{' || c == '[') { stack.push(c); } else { if (stack.isEmpty()) return false; char top = stack.pop(); if (c == ')' && top != '(') return false; if (c == '}' && top != '{') return false; if (c == ']' && top != '[') return false; } } return stack.isEmpty();}13. 문자열 압축연속된 같은 문자를 묶어 표현하는 문제다."aaabbc" → "a3b2c1" 또는 "3a2b1c"String compress(String s) { StringBuilder sb = new StringBuilder(); int i = 0; while (i < s.length()) { char c = s.charAt(i); int count = 0; while (i < s.length() && s.charAt(i) == c) { count++; i++; } sb.append(c).append(count); } return sb.toString();}카카오 “문자열 압축” 문제는 이 아이디어를 단위 길이별로 확장한 것이다.14. 사전순 최소/최대문자열에서 특정 조건을 만족하는 사전순 최소/최대를 구하는 문제가 있다.핵심은 보통 스택(그리디) 이다.예: “주어진 문자열에서 K개를 제거해서 사전순 최소 만들기”String removeK(String s, int k) { Deque<Character> stack = new ArrayDeque<>(); int removed = 0; for (char c : s.toCharArray()) { while (!stack.isEmpty() && removed < k && stack.peek() > c) { stack.pop(); removed++; } stack.push(c); } while (removed < k) { stack.pop(); removed++; } StringBuilder sb = new StringBuilder(); for (char c : stack) sb.append(c); return sb.reverse().toString();}15. 자주 하는 실수1) String을 루프에서 + 연결// O(N²) — 절대 금지for (...) result += s;StringBuilder를 쓰자.2) == 로 문자열 비교내용 비교에는 반드시 equals를 써야 한다.3) substring 인덱스 실수substring(start, end)에서 end는 포함되지 않는다.4) charAt으로 바로 연산할 때 형 변환 실수char c = '9';int n = c; // 57 (ASCII 값)int n = c - '0'; // 9 (올바른 변환)5) split 인자에 특수문자s.split("."); // 정규식이라 모든 문자에 매칭됨s.split("\\."); // 올바른 마침표 분할|, *, +, . 등은 정규식 특수문자이므로 이스케이프가 필요하다.16. 실전 판단 기준문자열 문제를 만나면 다음을 먼저 판단하면 된다.1. 단순 조작인가? → 기본 메서드 활용2. 패턴 매칭인가? → KMP, 해싱3. 빈도/구성인가? → 해시맵, 빈도 배열4. 순서/정렬인가? → Comparator5. 괄호/짝인가? → 스택6. 최적 선택인가? → 그리디 + 스택17. 시험장용 최소 암기 버전문자열 기본:StringBuilder 필수 (루프 연결 금지)equals로 비교c - 'a' / c - '0' 변환핵심 패턴:빈도: int[26] 또는 HashMap팰린드롬: 양끝 투 포인터괄호: 스택압축: 연속 카운팅사전순 최소: 스택 + 그리디주의:substring end 미포함split 정규식 이스케이프String 불변 → StringBuilder 가변18. 최종 요약문자열은 다음 문장으로 정리할 수 있다.문자의 배열을 효율적으로 조작하기 위해기본 메서드, 빈도 배열, 스택, 해시 등을 적절히 조합하는 유형핵심만 다시 압축하면: StringBuilder는 문자열 조작의 필수 도구다 char - 'a' 변환은 빈도 배열의 기본이다 괄호 문제는 스택, 팰린드롬은 투 포인터가 정석이다 정렬 기준이 복잡하면 Comparator를 직접 정의한다 롤링 해시와 전처리를 쓰면 부분 문자열의 해시값을 O(1)에 비교할 수 있다문제를 보면 먼저 이 질문을 하면 된다.이 문자열을 어떤 단위로 쪼개고,어떤 자료구조로 관리하면 조건을 효율적으로 처리할 수 있는가?
- Stack Algorithm algorithm stack Stack Stack스택(Stack)은 가장 나중에 넣은 값을 가장 먼저 꺼내는 자료구조다.한 줄로 요약하면 다음과 같다.마지막에 들어온 데이터가 먼저 나간다Last In First Out, LIFO1. 언제 쓰는가문제에서 아래 표현이 보이면 스택을 먼저 떠올리면 된다. 가장 최근 것부터 처리 되돌아가기 괄호 짝 맞추기 직전 상태 추적 현재 값보다 큰 값/작은 값 중 가장 가까운 것 수식을 왼쪽에서 오른쪽으로 처리하면서 중간 결과 보관특히 코테에서는 아래 두 가지가 가장 자주 나온다. 기본 스택: 괄호, 문자열 제거, 되돌리기 단조 스택: 오큰수, 탑, 히스토그램, 보이는 빌딩2. 스택의 핵심 개념스택은 입구가 하나뿐인 상자처럼 생각하면 된다.push : 맨 위에 넣기pop : 맨 위에서 꺼내기peek : 맨 위 값 보기예를 들어:push(3)push(7)push(2)상태:bottom [3, 7, 2] top이때 pop()을 하면 2가 나온다.그 다음 pop()을 하면 7이 나온다.즉 가장 최근 값부터 처리된다.flowchart LR subgraph PUSH["push(3) → push(7) → push(2)"] direction TB T2["2 ← top"] T7["7"] T3["3"] end PUSH --> POP["pop()"] POP --> R["2 반환"] POP --> AFTER subgraph AFTER["pop 이후 상태"] direction TB A7["7 ← top"] A3["3"] end스택은 항상 맨 위(top)에서만 삽입과 삭제가 일어난다. push(2) 이후 pop()을 하면 가장 마지막에 넣은 2가 나온다.3. 왜 필요한가배열이나 리스트만으로도 많은 문제를 풀 수 있지만,스택은 특히 최근 상태 하나를 빠르게 확인하고 제거하는 상황에 강하다.예를 들어 괄호 문제를 생각해 보자.(()())문자를 왼쪽부터 보면서: 여는 괄호 ( 는 넣어 두고 닫는 괄호 ) 를 만나면 가장 최근의 ( 와 짝을 맞춘다이 동작은 “가장 최근에 열렸고 아직 닫히지 않은 괄호”를 찾아야 하므로 스택과 정확히 맞아떨어진다.4. 기본 연산1) push스택 맨 위에 원소를 넣는다.2) pop스택 맨 위 원소를 꺼내면서 제거한다.3) peek스택 맨 위 원소를 제거하지 않고 확인한다.4) isEmpty스택이 비어 있는지 확인한다.이 네 개가 사실상 전부다.5. Java에서는 무엇을 쓰는가Java에서 스택 문제를 풀 때는 보통 Stack 클래스보다 ArrayDeque를 더 많이 쓴다.이유: 더 빠른 편이다 메서드가 명확하다 단조 스택 구현에도 잘 맞는다권장 패턴:ArrayDeque<Integer> stack = new ArrayDeque<>();stack.push(10); // 맨 위에 넣기int top = stack.peek();int x = stack.pop();boolean empty = stack.isEmpty();주의: ArrayDeque에는 null을 넣지 않는 것이 좋다 스택처럼 쓸 때는 push, pop, peek를 일관되게 쓰는 편이 안전하다6. 가장 기본적인 예시: 괄호 문자열 검사문제:문자열이 올바른 괄호 문자열인지 판별하라아이디어: ( 를 보면 push ) 를 보면 pop 그런데 pop할 것이 없으면 잘못된 문자열 마지막에 스택이 비어 있어야 올바른 문자열import java.util.*;class Solution { boolean isValid(String s) { ArrayDeque<Character> stack = new ArrayDeque<>(); for (int i = 0; i < s.length(); i++) { char ch = s.charAt(i); if (ch == '(') { stack.push(ch); } else { if (stack.isEmpty()) return false; stack.pop(); } } return stack.isEmpty(); }}이 문제의 본질은 다음과 같다.가장 최근에 열린 괄호와 현재 닫는 괄호를 매칭한다즉 LIFO 구조가 그대로 쓰인다.7. 스택으로 보는 “직전 상태 관리”스택은 단순히 괄호만 푸는 도구가 아니다.아래처럼 “방금 전 상태”를 계속 기억해야 하는 문제에서 자주 나온다. 문자열 폭발 백스페이스 처리 Undo 기능 브라우저 뒤로 가기 연산 중간 상태 보관예를 들어 문자열에서 abc를 지우는 문제라면,문자를 하나씩 스택에 넣고 뒤에서 패턴이 맞는지 확인하는 식으로 처리할 수 있다.즉 스택은 “온라인으로 처리하면서 뒤쪽만 빠르게 건드리는 구조”에 강하다.8. 코테에서 진짜 중요한 것: 단조 스택실전에서 스택 문제의 핵심은 대부분 단조 스택(Monotonic Stack) 이다.단조 스택은 말 그대로:스택 안의 값이 항상 증가하거나 감소하는 형태를 유지하는 스택이다.왜 이런 구조가 필요할까?많은 문제에서 필요한 것은 모든 후보가 아니다.오직 아직 의미가 남아 있는 후보들만 유지하면 된다.대표 문제: BOJ 2493 탑 BOJ 17298 오큰수 BOJ 6198 옥상 정원 꾸미기 BOJ 3015 오아시스 재결합 BOJ 6549 히스토그램에서 가장 큰 직사각형9. 단조 스택의 핵심 아이디어다음 문제를 생각해 보자.각 위치 i에 대해오른쪽에서 처음 만나는 나보다 큰 수를 구하라이게 오큰수(Next Greater Element)다.배열을 오른쪽에서 왼쪽으로 보자.현재 값이 cur일 때,스택 위에 cur보다 작거나 같은 값들이 있다면 그 값들은 앞으로 쓸모가 없다.왜냐하면: 그 값들은 cur보다 작거나 같고 cur가 더 왼쪽에 있으면서 더 가깝기 때문에 미래의 어떤 원소 입장에서도 그 값들보다 cur가 더 좋은 후보가 된다즉, 쓸모 없어진 후보는 즉시 제거해도 된다.이게 단조 스택의 본질이다.flowchart TD A["현재 값 읽기"] --> B{"Top이 불필요한가"} B -->|Yes| C["Pop"] C --> B B -->|No| D["Top이 답이 됨"] D --> E["현재 값 Push"]단조 스택은 이 그림처럼 현재 값이 들어왔을 때 쓸모 없는 후보를 먼저 제거하고, 남은 top만 답 후보로 쓰는 구조다.10. 왜 O(n)이 되는가단조 스택이 강력한 이유는 시간 복잡도 때문이다.겉보기에는 while문 때문에 느려 보일 수 있다.하지만 각 원소는: 스택에 한 번 push 많아야 한 번 pop된다.즉 전체적으로 보면 각 원소가 최대 두 번만 스택과 상호작용한다.그래서 전체 시간 복잡도는:O(n)이다.이게 단순 이중 반복문 O(n^2)과 결정적으로 다르다.11. 단조 스택의 4가지 대표 패턴문제를 보면 결국 아래 4개 중 하나인 경우가 많다. 유형 의미 Previous Greater 왼쪽에서 가장 가까운 더 큰 값 Previous Smaller 왼쪽에서 가장 가까운 더 작은 값 Next Greater 오른쪽에서 가장 가까운 더 큰 값 Next Smaller 오른쪽에서 가장 가까운 더 작은 값 예: 탑: Previous Greater 오큰수: Next Greater 히스토그램: Previous Smaller, Next Smaller이 표를 머릿속에 넣어 두면 문제를 많이 빨리 분류할 수 있다.12. BOJ 2493 탑으로 이해하는 단조 스택문제 핵심:각 탑에서 왼쪽으로 레이저를 쐈을 때처음 만나는 자신보다 높거나 같은 탑의 번호를 구하라이 말은 곧:왼쪽에서 가장 가까운 더 크거나 같은 값 찾기즉 Previous Greater 패턴이다.핵심 관찰현재 탑 높이가 h일 때,왼쪽 후보들 중 h보다 낮은 탑은 더 이상 의미가 없다.왜냐하면 지금 h가 등장한 순간,그 낮은 탑들은 앞으로 나올 오른쪽 탑들의 입장에서: 더 낮고 더 멀기 때문즉 후보 자격을 잃는다.구현 흐름왼쪽에서 오른쪽으로 진행하면서: 현재 높이보다 낮은 탑들을 pop 남아 있는 top이 정답 현재 탑을 pushimport java.util.*;class Solution { static class Tower { int height; int idx; Tower(int height, int idx) { this.height = height; this.idx = idx; } } int[] solve(int[] arr) { int n = arr.length; int[] ans = new int[n]; ArrayDeque<Tower> stack = new ArrayDeque<>(); for (int i = 0; i < n; i++) { int h = arr[i]; while (!stack.isEmpty() && stack.peek().height < h) { stack.pop(); } if (stack.isEmpty()) ans[i] = 0; else ans[i] = stack.peek().idx + 1; stack.push(new Tower(h, i)); } return ans; }}여기서 비교가 < 인 이유는 문제에서 “나보다 높거나 같은 탑”도 정답이기 때문이다.13. BOJ 17298 오큰수로 이해하는 단조 스택문제 핵심:각 원소에 대해오른쪽에서 처음 만나는 자신보다 큰 수를 구하라즉 Next Greater 패턴이다.왜 오른쪽에서 왼쪽으로 순회하는가현재 원소의 오른쪽 정보가 필요하기 때문이다.오른쪽에서 왼쪽으로 오면 스택에는 이미 오른쪽 후보들이 쌓여 있다.구현 흐름현재 값 cur에 대해: cur보다 작거나 같은 값들을 pop 남은 top이 오큰수 현재 값을 pushimport java.util.*;class Solution { int[] nextGreater(int[] arr) { int n = arr.length; int[] ans = new int[n]; ArrayDeque<Integer> stack = new ArrayDeque<>(); for (int i = n - 1; i >= 0; i--) { int cur = arr[i]; while (!stack.isEmpty() && stack.peek() <= cur) { stack.pop(); } ans[i] = stack.isEmpty() ? -1 : stack.peek(); stack.push(cur); } return ans; }}여기서는 비교가 <= 인 이유가 중요하다. 오큰수는 “엄Strictly greater” 즉 진짜 더 큰 수가 필요하다 같은 값은 정답 후보가 될 수 없다14. 값이 아니라 인덱스를 넣는 이유단조 스택에서는 값을 넣을 수도 있고 인덱스를 넣을 수도 있다.실전에서는 인덱스를 넣는 경우가 더 많다.이유: 정답이 위치인 경우가 많다 거리 계산이 필요할 수 있다 실제 값은 arr[idx]로 다시 접근하면 된다예:ArrayDeque<Integer> stack = new ArrayDeque<>(); // index stackwhile (!stack.isEmpty() && arr[stack.peek()] <= arr[i]) { stack.pop();}이 방식은 다음 문제에서 특히 유용하다. 탑 번호 출력 며칠 뒤 더 따뜻한 날인지 거리 계산 히스토그램 폭 계산15. < 와 <= 가 왜 중요한가단조 스택에서 가장 자주 틀리는 부분이다.예를 들어 “나보다 큰 값”을 찾는 문제라면,같은 값은 후보가 아니므로 pop 조건에 <= 가 들어간다.반대로 “나보다 크거나 같은 값”을 찾는 문제라면,같은 값은 후보가 될 수 있으므로 pop 조건이 < 가 된다.즉 기준은 다음과 같다. 정답 조건이 > 이면 pop 조건에 <= 정답 조건이 >= 이면 pop 조건에 < 정답 조건이 < 이면 pop 조건에 >= 정답 조건이 <= 이면 pop 조건에 >비교 연산자 하나 차이로 정답이 통째로 틀릴 수 있으므로 반드시 문제 문장을 정확히 읽어야 한다.16. 히스토그램 문제에서 왜 더 어려운가BOJ 6549 같은 히스토그램 최대 직사각형 문제는 스택 문제 중 대표적인 상급 문제다.핵심은 각 막대에 대해: 왼쪽으로 어디까지 확장 가능한가 오른쪽으로 어디까지 확장 가능한가를 찾아서 폭을 구하는 것이다.즉 단순히 “가장 가까운 큰 값” 하나를 찾는 문제가 아니라,경계 전체를 찾아야 한다.보통은 단조 증가 스택을 써서,현재 높이보다 작은 높이를 만났을 때 이전 막대들의 직사각형 넓이를 계산한다.이 문제는 단조 스택의 확장형으로 생각하면 된다.17. 스택과 큐의 차이헷갈리기 쉬우므로 같이 정리해 두자. 자료구조 꺼내는 순서 Stack 가장 나중에 넣은 것부터 Queue 가장 먼저 넣은 것부터 즉: 스택은 최근 상태 중심 큐는 들어온 순서 중심BFS는 큐,단조 스택 문제는 스택이 기본이다.18. 자주 하는 실수1) 방향을 잘못 잡음 오른쪽 정보가 필요하면 보통 오른쪽에서 왼쪽 왼쪽 정보가 필요하면 보통 왼쪽에서 오른쪽2) 비교 연산자를 잘못 씀<, <=, >, >= 중 어떤 것이 맞는지 문제 문장을 기준으로 결정해야 한다.3) 값이 필요한데 인덱스를 넣거나, 인덱스가 필요한데 값만 넣음정답이 무엇인지 먼저 정해야 한다. 값이 필요한가 위치가 필요한가 거리까지 필요한가4) Stack 클래스를 쓰다가 성능/코드 스타일이 꼬임실전에서는 ArrayDeque가 더 깔끔하다.5) 단조 스택인데 while이 아니라 if만 사용현재 값보다 작거나 큰 후보를 연속해서 제거해야 하므로 대부분 while이 맞다.19. 실전 판단 기준문제에서 아래 표현이 보이면 거의 단조 스택을 의심하면 된다. 가장 가까운 처음 만나는 왼쪽 / 오른쪽 나보다 큰 / 작은 신호를 받는 첫 번째 탑 볼 수 있는 건물 다음 더 따뜻한 날그리고 입력 크기가 커서:N이 100000 이상인데가까운 큰/작은 값을 구하라처럼 나오면 이중 반복문 O(n^2)은 거의 불가능하므로 단조 스택일 가능성이 높다.20. 시험장용 최소 암기 버전스택:LIFOpush / pop / peek코테 핵심:단조 스택단조 스택 본질:쓸모 없어진 후보를 즉시 제거패턴:Previous GreaterPrevious SmallerNext GreaterNext Smaller복잡도:각 원소는 push 1번, pop 1번 -> O(n)주의:방향비교 연산자값 vs 인덱스21. 최종 요약스택은 다음 문장으로 정리할 수 있다.가장 최근에 넣은 데이터를 먼저 처리하는 자료구조그리고 코테에서의 핵심은 다음이다.단조 스택은불필요해진 후보를 즉시 제거해서가장 가까운 큰 값/작은 값을 O(n)에 찾는 기법이다핵심만 다시 압축하면: 기본 스택은 괄호, 되돌리기, 문자열 처리에 사용 실전 핵심은 단조 스택 단조 스택은 가까운 큰/작은 값 문제를 O(n)에 해결 while로 의미 없는 후보를 제거 문제 문장에 따라 비교 연산자를 정확히 선택 필요에 따라 값 대신 인덱스를 스택에 저장스택 문제를 풀 때는 항상 먼저 물어보면 된다.내가 유지해야 하는 것은 모든 값인가,아니면 아직 의미 있는 후보들만인가?이 질문의 답이 “후보들만”이라면 단조 스택일 가능성이 높다.
- Sorting Algorithm algorithm sorting Sorting Sorting정렬(Sorting)은 데이터를 특정 기준에 따라 순서대로 재배치하는 알고리즘이다.한 줄로 요약하면 다음과 같다.원소를 비교 기준에 맞게 줄 세우는 작업정렬은 거의 모든 알고리즘의 기초이면서,이분 탐색, 투 포인터, 그리디, 좌표 압축 등 다른 기법의 전제 조건이 되는 경우가 매우 많다.1. 왜 중요한가코딩테스트에서 정렬 자체를 직접 구현하는 문제는 많지 않다.하지만 정렬이 중요한 이유는 다음과 같다. 이분 탐색의 전제: 배열이 정렬되어야 함 투 포인터의 전제: 양끝 투 포인터는 정렬 필요 그리디의 핵심: 대부분 정렬 후 순서대로 처리 좌표 압축의 기반: 좌표를 정렬한 뒤 인덱싱 중복 제거: 정렬 후 인접 비교즉 정렬 자체가 답인 경우보다,정렬이 다른 알고리즘의 전처리로 쓰이는 경우가 훨씬 많다.2. 정렬 알고리즘 분류 알고리즘 시간 복잡도 (평균) 안정성 특징 버블 정렬 O(N²) 안정 느림, 거의 안 씀 선택 정렬 O(N²) 불안정 단순하지만 느림 삽입 정렬 O(N²) 안정 거의 정렬된 데이터에 강함 병합 정렬 O(N log N) 안정 추가 메모리 필요 퀵 정렬 O(N log N) 불안정 실전에서 가장 빠른 편 힙 정렬 O(N log N) 불안정 추가 메모리 없음 계수 정렬 O(N + K) 안정 값 범위 K가 작을 때 코테에서는 보통 Arrays.sort()나 Collections.sort()를 쓰므로,직접 구현보다는 각 알고리즘의 특징과 쓰임새를 이해하는 것이 더 중요하다.3. 안정 정렬이란 무엇인가안정 정렬(Stable Sort)은 다음을 보장한다.값이 같은 두 원소의 원래 순서가 정렬 후에도 유지된다예를 들어 학생을 점수로 정렬할 때,같은 점수인 학생들은 원래 입력 순서가 유지되어야 하는 경우가 있다.Java에서: Arrays.sort(int[]) → Dual-Pivot Quicksort → 불안정 Arrays.sort(Object[]) → TimSort → 안정 Collections.sort() → TimSort → 안정즉 객체 배열이나 리스트를 정렬하면 안정 정렬이고,기본형 배열(int[])은 불안정 정렬이다.4. Java 기본 정렬배열 정렬int[] arr = {5, 2, 8, 1, 3};Arrays.sort(arr);역순 정렬기본형 배열은 역순 정렬이 바로 안 된다.Integer[]를 쓰거나 직접 뒤집어야 한다.Integer[] arr = {5, 2, 8, 1, 3};Arrays.sort(arr, Collections.reverseOrder());리스트 정렬List<Integer> list = new ArrayList<>(Arrays.asList(5, 2, 8, 1, 3));Collections.sort(list);5. 커스텀 정렬이 핵심이다코테에서 가장 자주 나오는 정렬 문제는 비교 기준을 직접 정하는 문제다.예를 들어: 끝나는 시간 기준 정렬 길이 기준, 같으면 사전순 x좌표 기준, 같으면 y좌표 기준Comparator 람다Arrays.sort(arr, (a, b) -> { if (a[0] != b[0]) return Integer.compare(a[0], b[0]); return Integer.compare(a[1], b[1]);});객체 정렬 예시static class Student { String name; int score; Student(String name, int score) { this.name = name; this.score = score; }}List<Student> students = ...;students.sort((a, b) -> { if (a.score != b.score) return Integer.compare(b.score, a.score); // 내림차순 return a.name.compareTo(b.name); // 이름 오름차순});6. 정렬 기준 실수 주의a - b 대신 Integer.compare(a, b)를 쓰자// 위험: overflow 가능Arrays.sort(arr, (a, b) -> a - b);// 안전Arrays.sort(arr, (a, b) -> Integer.compare(a, b));a - b는 값이 매우 크거나 음수일 때 overflow가 발생할 수 있다.7. 계수 정렬 Counting Sort값의 범위가 작을 때 매우 빠르다.핵심 아이디어:각 값의 등장 횟수를 세고그 횟수대로 순서대로 배치한다void countingSort(int[] arr, int maxVal) { int[] count = new int[maxVal + 1]; for (int x : arr) { count[x]++; } int idx = 0; for (int val = 0; val <= maxVal; val++) { while (count[val] > 0) { arr[idx++] = val; count[val]--; } }}언제 쓰는가: 값의 범위가 작고 정수일 때 비교 기반 정렬보다 빨라야 할 때 빈도 분석이 함께 필요할 때flowchart TD A["발생 횟수 세기"] --> B["값을 작은 순서대로 순회"] B --> C["count[val]만큼 결과에 기록"]위 흐름은 이 문서의 구현처럼 “값 범위가 작을 때 그대로 복원하는” 가장 단순한 계수 정렬을 나타낸다.안정 정렬까지 필요하면 count를 누적합으로 바꿔 각 값의 마지막 위치를 계산하는 버전을 쓴다.8. 좌표 압축좌표 압축(Coordinate Compression)은 정렬의 대표적인 응용이다.핵심 아이디어:큰 범위의 값을 작은 인덱스로 변환한다예를 들어 값이 {100, 5000, 200, 5000, 300}이면,정렬 후 중복 제거하면 {100, 200, 300, 5000}이고,각 값을 0, 1, 2, 3으로 바꿀 수 있다.int[] compress(int[] arr) { int n = arr.length; int[] sorted = arr.clone(); Arrays.sort(sorted); // 중복 제거 int[] unique = new int[n]; int size = 0; for (int x : sorted) { if (size == 0 || unique[size - 1] != x) { unique[size++] = x; } } Map<Integer, Integer> map = new HashMap<>(); for (int i = 0; i < size; i++) { map.put(unique[i], i); } int[] result = new int[n]; for (int i = 0; i < n; i++) { result[i] = map.get(arr[i]); } return result;}좌표 압축이 자주 쓰이는 곳: 세그먼트 트리에 좌표 범위가 너무 클 때 값은 크지만 실제 종류는 적을 때 순서 관계만 중요하고 절대값은 중요하지 않을 때flowchart LR A["원본<br>100, 5000, 200, 5000, 300"] --> B["정렬<br>100, 200, 300, 5000, 5000"] B --> C["중복 제거<br>100, 200, 300, 5000"] C --> D["인덱싱<br>0, 1, 2, 3"] D --> E["결과<br>0, 3, 1, 3, 2"]좌표 압축은 이처럼 “값 → 정렬 → 중복 제거 → 인덱스 매핑”의 4단계로 이루어진다.9. 위상 정렬과의 관계숫자를 크기 순으로 정렬하는 것은 일반 정렬이고,선후 관계를 지키며 정렬하는 것은 위상 정렬이다.즉 정렬은 넓게 보면: 일반 정렬: 값 비교 기반 위상 정렬: 관계(간선) 기반으로 나뉜다.10. 정렬 문제 접근 순서정렬 문제를 보면 아래 순서로 생각하면 된다.1. 무엇을 기준으로 정렬하는가?2. 기준이 여러 개면 우선순위는?3. 안정 정렬이 필요한가?4. 정렬 후 다른 알고리즘(이분 탐색, 투 포인터 등)이 이어지는가?이 체크리스트는 단순히 순서를 외우기보다,비교 기준, 안정성, 정렬 후 후처리 세 축으로 문제를 읽는 습관을 만드는 데 의미가 있다.특히 코테에서는 정렬 자체보다 “정렬 뒤에 무엇을 할 것인가”가 정답의 핵심인 경우가 훨씬 많다.11. 자주 하는 실수1) a - b overflowInteger.compare를 쓰는 습관을 들이자.2) 정렬 기준을 잘못 세움문제를 정확히 읽지 않으면 1차 기준과 2차 기준을 혼동하기 쉽다.3) 기본형 배열과 객체 배열의 정렬 방식 차이를 모름int[]는 Comparator를 바로 넣을 수 없다.Integer[]이나 List<Integer>를 써야 한다.4) 안정 정렬이 필요한 문제인데 불안정 정렬을 씀같은 값의 순서가 문제 조건에 영향을 줄 수 있다.12. 시험장용 최소 암기 버전정렬:Arrays.sort() / Collections.sort()커스텀 정렬:Comparator 람다안정 정렬:Collections.sort() → TimSort좌표 압축:정렬 → 중복 제거 → 인덱싱주의:a - b overflow기본형 역순 정렬 불가13. 최종 요약정렬은 다음 문장으로 정리할 수 있다.데이터를 기준에 따라 순서대로 재배치하는 기본 알고리즘이자,다른 알고리즘의 전처리로 가장 많이 쓰이는 기법문제를 보면 먼저 이 질문을 하면 된다.이 데이터를 어떤 기준으로 줄 세우면문제가 단순해지는가?정렬은 문제를 단순하게 만드는 첫 번째 도구다.
- Segment Tree Algorithm algorithm segment-tree Segment Tree Segment Tree세그먼트 트리(Segment Tree)는 구간 정보를 빠르게 질의하고 갱신하기 위한 트리 기반 자료구조다.한 줄로 요약하면 다음과 같다.배열 구간을 트리로 나눠서구간 합 / 최솟값 / 최댓값을 빠르게 처리한다누적합은 구간 합 조회에는 강하지만 값이 자주 바뀌는 상황에 약하다.세그먼트 트리는 이 부분을 해결한다.1. 언제 쓰는가문제에서 아래 조합이 보이면 세그먼트 트리를 떠올리면 된다. 구간 합 / 구간 최솟값 / 구간 최댓값 배열 값이 계속 바뀜 쿼리 수가 많음 업데이트와 질의가 섞여 있음즉:구간 쿼리 + 업데이트이 같이 나오면 대표 후보다.2. 왜 누적합만으로 안 되는가예를 들어 배열이 있고 구간 합 쿼리가 많다면 누적합으로 충분하다.하지만 배열 값이 바뀌면 누적합 전체를 다시 계산해야 할 수 있다.예: arr[3] 값이 바뀜 그 뒤의 모든 누적합이 영향을 받음즉 업데이트가 많으면 비효율적이다.세그먼트 트리는 이 문제를 로그 시간으로 줄인다.3. 핵심 아이디어배열 구간을 반씩 쪼개며 트리로 저장한다.예를 들어 길이 8 배열이면:[1..8] -> [1..4], [5..8] -> [1..2], [3..4], [5..6], [7..8]flowchart TD A["1~8 구간"] --> B["1~4 구간"] A --> C["5~8 구간"] B --> D["1~2 구간"] B --> E["3~4 구간"] C --> F["5~6 구간"] C --> G["7~8 구간"] D --> H["1"] D --> I["2"]각 노드는 자기 구간의 정보를 저장한다.예를 들어 구간 합 세그트리라면: 루트는 전체 구간 합 왼쪽 자식은 왼쪽 절반 구간 합 오른쪽 자식은 오른쪽 절반 구간 합을 저장한다.4. 작은 예시로 이해하기배열:index: 1 2 3 4value: 5 8 6 3그럼 구간 합 세그트리는 다음 의미를 가진다. [1..4] 합 = 22 [1..2] 합 = 13 [3..4] 합 = 9 [1..1] = 5 [2..2] = 8 [3..3] = 6 [4..4] = 3즉 트리의 각 노드가 배열 일부를 대표하는 셈이다.이 예시에서 실제 저장값을 그림처럼 보면 더 쉽다. [1..4] = 22 / \ [1..2] = 13 [3..4] = 9 / \ / \ [1]=5 [2]=8 [3]=6 [4]=3즉 리프는 원본 배열 값이고,내부 노드는 자식 둘을 합쳐 만든 값이다.5. 왜 O(log N)인가구간을 매번 반으로 나누므로,트리 높이는 대략 log N이다.그래서: 점 업데이트는 루트에서 리프까지 한 경로만 보면 된다 구간 쿼리도 필요한 노드만 골라 보면 된다즉 둘 다 O(log N) 수준으로 처리된다.정확히는 쿼리에서 한 층마다 많아야 몇 개의 노드만 실질적으로 방문하게 된다.구간이 반씩 쪼개지므로 높이는 log N이고,불필요한 가지는 "완전히 밖" 판정으로 바로 잘린다.6. 구간 쿼리의 3가지 경우구간 [left, right]를 물을 때,현재 노드가 담당하는 구간 [start, end]와의 관계는 세 가지다.1) 완전히 밖겹치지 않으면 무시2) 완전히 안현재 노드 값을 그대로 사용3) 일부만 겹침왼쪽 자식, 오른쪽 자식으로 내려가서 합친다이 3가지 분기가 세그트리 쿼리의 핵심이다.flowchart TD A["현재 노드 구간"] --> B{"쿼리 범위 밖"} B -->|Yes| C["항등원 반환"] B -->|No| D{"쿼리 범위에 완전히 포함"} D -->|Yes| E["현재 노드 값 사용"] D -->|No| F["왼쪽/오른쪽 자식으로 분할"]여기서 identity value는 연산에 따라 달라진다. 구간 합이면 0 구간 최솟값이면 아주 큰 값 INF 구간 최댓값이면 아주 작은 값즉 "겹치지 않는 경우 무엇을 돌려줘야 합치는 데 문제가 없는가"를 항상 먼저 생각해야 한다.7. 구간 합 세그먼트 트리 구현static class SegmentTree { long[] tree; int n; SegmentTree(long[] arr) { n = arr.length - 1; // 1-based input tree = new long[4 * n]; build(arr, 1, 1, n); } long build(long[] arr, int node, int start, int end) { if (start == end) { return tree[node] = arr[start]; } int mid = (start + end) / 2; long left = build(arr, node * 2, start, mid); long right = build(arr, node * 2 + 1, mid + 1, end); return tree[node] = left + right; } long query(int node, int start, int end, int left, int right) { if (right < start || end < left) return 0; if (left <= start && end <= right) return tree[node]; int mid = (start + end) / 2; return query(node * 2, start, mid, left, right) + query(node * 2 + 1, mid + 1, end, left, right); } void update(int node, int start, int end, int idx, long diff) { if (idx < start || idx > end) return; tree[node] += diff; if (start == end) return; int mid = (start + end) / 2; update(node * 2, start, mid, idx, diff); update(node * 2 + 1, mid + 1, end, idx, diff); }}이 방식은 diff를 더하는 방식이다.즉 arr[idx]가 old에서 new로 바뀌었다면:long diff = newValue - oldValue;update(1, 1, n, idx, diff);처럼 호출한다.실전에서는 이 점을 자주 놓친다.세그트리는 보통 "새 값을 직접 넣는 것"이 아니라"변화량을 전파하는 것"부터 떠올리는 편이 구현이 단순하다.8. 쿼리를 손으로 따라가 보기예를 들어 [2..3] 합을 구하고 싶다고 하자.배열:[1..4] = [5, 8, 6, 3]루트 [1..4]는 일부만 겹친다.따라서 자식 둘로 내려간다. [1..2]는 일부 겹침 -> 더 내려감 [3..4]도 일부 겹침 -> 더 내려감결국 필요한 것은: [2..2] = 8 [3..3] = 6두 개만 더하면 된다.즉 전체를 다 보지 않고 필요한 구간만 내려간다.9. 점 업데이트는 어떻게 되나예를 들어 arr[3]이 6 -> 10으로 바뀌었다고 하자.차이는 +4다.그러면 3을 포함하는 구간의 노드들만 갱신하면 된다.즉: [3..3] [3..4] [1..4]이런 경로만 바뀐다.이게 점 업데이트가 O(log N)인 이유다.즉 업데이트도 결국:루트에서 리프까지 한 줄만 고친다고 이해하면 된다.10. 합이 아닌 다른 연산도 가능한가가능하다.세그먼트 트리는 구간 정보만 잘 합칠 수 있으면 된다.예: 구간 합 구간 최솟값 구간 최댓값 구간 gcd 구간 xor즉 merge 연산만 바꾸면 다양한 문제에 적용된다.다만 모든 연산이 되는 것은 아니다.세그트리는 "두 구간 결과를 합쳐서 부모 결과를 만들 수 있는가"가 핵심이다.예를 들어 합, 최소, 최대, gcd, xor는 잘 맞지만,문제에 따라서는 추가 정보가 더 필요할 수 있다.11. Lazy Propagation은 언제 필요한가기본 세그트리는 점 업데이트에 강하다.하지만 아래처럼 구간 업데이트가 많으면 비효율적일 수 있다.[2..100000]에 모두 +5 하라이런 경우 Lazy Propagation을 쓴다.즉: 점 업데이트만 많다 -> 기본 세그트리 구간 업데이트도 많다 -> Lazy 세그트리이 차이를 꼭 구분해야 한다. arr[5] = 10 같은 한 점 변경이면 기본 세그트리 [2..100000] 전체에 +5면 Lazy 필요 가능성 큼즉 업데이트의 범위를 먼저 보고 자료구조 난이도를 결정하면 된다.Lazy의 핵심 아이디어구간 업데이트를 노드마다 미리 기록해 두고,실제로 필요할 때(쿼리나 하위 업데이트 시)만 자식에게 전파한다.flowchart TD A["구간 갱신"] --> B["Lazy 값 저장"] B --> C["나중에 자식 노드 접근 시"] C --> D["Lazy 값을 자식에게 전파"] D --> E["부모 Lazy 초기화"]즉 “게으르게” 전파하기 때문에 Lazy라고 부른다.매 업데이트마다 리프까지 내려가지 않고,필요한 시점까지 미루는 것이 핵심이다.12. 왜 배열 크기를 4 * n 정도로 잡는가세그트리를 배열로 구현할 때는 보통:tree = new long[4 * n];처럼 잡는다.이유는 트리 구조를 안전하게 담기 위한 여유 공간 때문이다.엄밀히 딱 맞는 크기를 계산할 수도 있지만,코테에서는 4 * n이 구현이 가장 간단하고 실수가 적다.13. 펜윅 트리와 비교 항목 Segment Tree Fenwick Tree 구현 더 복잡 더 단순 지원 연산 더 다양 주로 prefix sum 유연성 높음 중간 즉 합 전용이면 펜윅이 편하고,범용 구간 자료구조가 필요하면 세그트리가 더 좋다.14. 자주 하는 실수1) 구간 범위 [start, end]를 헷갈림세그트리는 인덱스 실수가 매우 자주 난다.2) 구간 밖 return 값을 잘못 둠구간 합이면 0, 최소값이면 INF처럼 연산에 맞는 항등값을 줘야 한다.3) 1-based / 0-based 혼동입력 배열과 세그트리 범위 기준을 통일해야 한다.4) 합이 큰데 int 사용합 문제는 long이 더 안전하다.15. 시험장용 최소 암기 버전세그트리:구간을 반씩 쪼개는 트리가능한 것:구간 합 / 최소 / 최대점 업데이트구간 쿼리핵심 분기:완전 밖 -> 버림완전 안 -> 현재 노드 사용일부 겹침 -> 자식으로 내려감복잡도:O(log N)16. 최종 요약세그먼트 트리는 다음 문장으로 정리할 수 있다.배열 구간을 반씩 쪼개어 저장해서구간 쿼리와 업데이트를 로그 시간에 처리하는 자료구조문제를 보면 먼저 이 질문을 하면 된다.값이 바뀌는 배열에서구간 정보를 여러 번 빠르게 구해야 하는가?그렇다면 세그트리 가능성이 높다.
- Prefix Sum Algorithm algorithm prefix-sum Prefix Sum Prefix Sum누적합(Prefix Sum)은 배열의 앞에서부터 합을 미리 저장해 두고, 구간 합을 빠르게 계산하는 기법이다.한 줄로 요약하면 다음과 같다.한 번 미리 더해 두고여러 번 빠르게 꺼내 쓰는 기법1. 언제 쓰는가문제에서 아래 표현이 보이면 누적합을 먼저 떠올리면 된다. 구간 합 연속 부분 배열의 합 여러 번의 합 쿼리 2차원 직사각형 합 부분합 개수 세기 합이 K인 구간 수 나머지가 같은 부분합누적합은 특히 다음 상황에서 강력하다.구간 합을 여러 번 물어보는데매번 처음부터 더하면 너무 느릴 때2. 핵심 아이디어배열이 다음과 같다고 하자.arr = [5, 2, 7, 3, 6]이때 prefix[i]를:arr의 처음부터 i개 원소의 합으로 정의하자.그러면:prefix[0] = 0prefix[1] = 5prefix[2] = 7prefix[3] = 14prefix[4] = 17prefix[5] = 23이제 구간 합 arr[l..r]은 다음처럼 한 번에 구할 수 있다.prefix[r + 1] - prefix[l]즉 매번 구간을 다시 더하지 않아도 된다.flowchart LR subgraph "누적합 배열" P0["P[0]=0"] --> P1["P[1]=5"] P1 --> P2["P[2]=7"] P2 --> P3["P[3]=14"] P3 --> P4["P[4]=17"] P4 --> P5["P[5]=23"] end누적합은 이 그림처럼 앞에서부터 합을 쌓아 두고, 필요한 구간은 두 누적합의 차이로 꺼내는 구조다.예를 들어 arr[1..3]의 합은 prefix[4] - prefix[1] = 17 - 5 = 12다.3. 왜 빠른가예를 들어 길이 N 배열에서 구간 합 질문이 M번 들어온다고 하자.매번 직접 더하면:O(N) x M이 걸릴 수 있다.하지만 누적합을 한 번 만들면: 전처리: O(N) 각 구간 합: O(1)이 된다.즉 전체가:O(N + M)수준으로 줄어든다.4. 1차원 누적합 기본 정의실전에서 가장 많이 쓰는 정의는 다음이다.prefix[i] = arr[0] + arr[1] + ... + arr[i - 1]즉 prefix를 한 칸 더 크게 만들어: prefix[0] = 0 prefix[1] = arr[0] prefix[2] = arr[0] + arr[1]처럼 관리한다.이 방식이 좋은 이유는 구간 합 공식이 깔끔해지기 때문이다.sum(l, r) = prefix[r + 1] - prefix[l]5. 1차원 누적합 구현int[] arr = {5, 2, 7, 3, 6};int n = arr.length;long[] prefix = new long[n + 1];for (int i = 0; i < n; i++) { prefix[i + 1] = prefix[i] + arr[i];}int l = 1;int r = 3;long sum = prefix[r + 1] - prefix[l]; // arr[1] + arr[2] + arr[3]long을 쓰는 이유는 합이 커질 수 있기 때문이다.6. 작은 예시로 이해하기배열:arr = [5, 2, 7, 3, 6]누적합:prefix = [0, 5, 7, 14, 17, 23]이제 구간 [1, 3]의 합을 구해 보자.arr[1] + arr[2] + arr[3] = 2 + 7 + 3 = 12공식으로는:prefix[4] - prefix[1] = 17 - 5 = 12즉 앞부분을 한 번에 빼는 방식이다.flowchart LR A["prefix[1] = 5<br>arr[0]까지의 합"] --> C["prefix[4] - prefix[1]"] B["prefix[4] = 17<br>arr[0]~arr[3]까지의 합"] --> C C --> D["arr[1..3]의 합 = 12"]즉 prefix[4] 안에는 필요한 구간과 필요 없는 앞부분이 함께 들어 있고,prefix[1]을 빼는 순간 앞부분만 정확히 제거된다.7. 누적합 문제를 푸는 기본 사고누적합 문제를 만나면 먼저 아래를 확인하면 된다. 여러 구간 합을 반복해서 묻는가? 부분 배열의 합 자체가 중요하거나, 그 합의 패턴이 중요한가? 매 쿼리를 직접 계산하면 느린가?이 질문들의 답이 예라면 누적합 가능성이 높다.8. 구간 합 쿼리 문제가장 기본적인 누적합 문제다.문제 예시:배열이 주어지고, 여러 개의 [l, r] 구간 합을 구하라import java.util.*;class Solution { long[] buildPrefix(int[] arr) { int n = arr.length; long[] prefix = new long[n + 1]; for (int i = 0; i < n; i++) { prefix[i + 1] = prefix[i] + arr[i]; } return prefix; } long rangeSum(long[] prefix, int l, int r) { return prefix[r + 1] - prefix[l]; }}이 유형은 BOJ 11659 같은 기본 문제에서 바로 나온다.9. 고정 길이 부분 배열의 합길이가 K인 모든 연속 부분 배열의 합을 구하는 문제도 누적합으로 쉽게 처리할 수 있다.예를 들어 길이 K의 구간 [i, i + K - 1] 합은:prefix[i + K] - prefix[i]이다.long maxFixedLengthSum(int[] arr, int k) { int n = arr.length; long[] prefix = new long[n + 1]; for (int i = 0; i < n; i++) { prefix[i + 1] = prefix[i] + arr[i]; } long answer = Long.MIN_VALUE; for (int i = 0; i + k <= n; i++) { answer = Math.max(answer, prefix[i + k] - prefix[i]); } return answer;}이 문제는 슬라이딩 윈도우로도 풀 수 있다. 구간 길이가 고정되어 있으면 슬라이딩 윈도우가 자연스럽고 여러 구간 합을 식으로 처리하고 싶으면 누적합이 자연스럽다10. 부분합 자체를 저장하면 무엇이 좋은가누적합의 진짜 힘은 단순 합 계산을 넘어서,부분합의 성질을 분석하는 문제로 확장된다는 점이다.예를 들어 구간 합 arr[l..r]은:prefix[r + 1] - prefix[l]이므로,구간 합 = K라는 조건은 곧:prefix[r + 1] - prefix[l] = K즉,prefix[l] = prefix[r + 1] - K로 바뀐다.이 관점이 중요하다.즉 “구간 문제”가 “부분합 값들의 관계 문제”로 바뀐다.11. 합이 K인 부분 배열 개수이제 누적합 + 해시맵의 대표 문제를 보자.문제:합이 K인 연속 부분 배열의 개수를 구하라현재 위치까지의 누적합을 sum이라고 하자.그러면 이전에 sum - K가 나온 적이 있다면,그 지점부터 현재까지의 구간 합은 K다.import java.util.*;class Solution { int countSubarraysSumK(int[] arr, int k) { Map<Long, Integer> freq = new HashMap<>(); freq.put(0L, 1); long sum = 0; int count = 0; for (int x : arr) { sum += x; count += freq.getOrDefault(sum - k, 0); freq.put(sum, freq.getOrDefault(sum, 0) + 1); } return count; }}이 방식은 음수가 있어도 된다.즉 슬라이딩 윈도우로 풀기 어려운 문제를 누적합으로 해결하는 대표 사례다.12. 나머지와 누적합누적합은 나머지 문제와 결합해도 매우 강력하다.예를 들어:합이 M으로 나누어떨어지는 부분 배열 개수를 구한다고 하자.구간 합 arr[l..r]는:prefix[r + 1] - prefix[l]이고 이것이 M으로 나누어떨어진다는 것은:prefix[r + 1] % M == prefix[l] % M과 같다.즉 같은 나머지를 가진 누적합 두 개를 고르는 문제가 된다.대표 예시: BOJ 10986 나머지 합13. 2차원 누적합누적합은 2차원 배열에서도 매우 중요하다.문제 예시:격자에서 직사각형 구간의 합을 여러 번 구하라2차원 누적합 prefix[i][j]를 다음처럼 정의한다.(1,1)부터 (i,j)까지의 직사각형 합그러면 직사각형 (x1, y1) ~ (x2, y2)의 합은:prefix[x2][y2]- prefix[x1 - 1][y2]- prefix[x2][y1 - 1]+ prefix[x1 - 1][y1 - 1]이다.마지막 +가 들어가는 이유는 두 번 뺀 영역을 다시 더해 주기 위해서다.이건 포함-배제 원리(Inclusion-Exclusion)다. 그림으로 보면 바로 이해된다.flowchart TB subgraph Whole["prefix[x2][y2] 전체 영역"] direction LR A["A<br>prefix[x1-1][y1-1]<br>두 번 빠진 모서리"] --- B["B<br>prefix[x1-1][y2]<br>위쪽에서 한 번 뺌"] C["C<br>prefix[x2][y1-1]<br>왼쪽에서 한 번 뺌"] --- D["답 구간<br>(x1, y1) ~ (x2, y2)"] end F["계산식: 전체 - B - C + A"] --> R["직사각형 구간 합"]즉 B와 C를 빼면 원하는 직사각형만 남을 것 같지만,왼쪽 위 모서리 A가 두 번 빠지므로 한 번 다시 더해 줘야 한다.14. 2차원 누적합 구현int n = 4;int m = 5;int[][] arr = new int[n + 1][m + 1];long[][] prefix = new long[n + 1][m + 1];for (int i = 1; i <= n; i++) { for (int j = 1; j <= m; j++) { prefix[i][j] = arr[i][j] + prefix[i - 1][j] + prefix[i][j - 1] - prefix[i - 1][j - 1]; }}long sum(int x1, int y1, int x2, int y2) { return prefix[x2][y2] - prefix[x1 - 1][y2] - prefix[x2][y1 - 1] + prefix[x1 - 1][y1 - 1];}대표 문제: BOJ 11660 구간 합 구하기 515. 누적합과 차분 배열의 관계차분 배열(Difference Array)은 누적합과 자주 짝을 이룬다.누적합이:많은 구간 합 조회를 빠르게 만드는 기법이라면,차분 배열은:많은 구간 업데이트를 빠르게 기록하는 기법이다.예를 들어 구간 [L, R]에 val을 더하고 싶다면,배열 전체를 다 바꾸지 않고:diff[L] += valdiff[R + 1] -= val만 기록한다.그 뒤 마지막에 한 번 누적합을 취하면 실제 값이 복원된다.즉 차분 배열은 누적합의 역방향 느낌으로 이해할 수 있다.예: 길이 6 배열에서 [2,4]에 +3, [3,5]에 +2diff: 0 0 +3 0 0 -3 0 +2 0 0 -2합산: 0 0 +3 +2 0 -3 -2누적합: 0 0 3 5 5 2 0→ 인덱스: 1 2 3 4 5 6→ 원래 의도: [2,4]에 3 더하고, [3,5]에 2 더한 결과16. 차분 배열 구현int n = 10;long[] diff = new long[n + 2];void rangeAdd(int l, int r, int val) { diff[l] += val; diff[r + 1] -= val;}long[] buildArray() { long[] arr = new long[n + 1]; for (int i = 1; i <= n; i++) { arr[i] = arr[i - 1] + diff[i]; } return arr;}이 기법은 다음 문제에서 자주 보인다. 구간 덧셈 쿼리 동시 접속자 수 구간별 이벤트 누적 imos법17. 도수 분포 배열도 함께 알아두면 좋다현재 메모에 있던 확장 주제라 같이 정리한다.도수 분포 배열(Frequency Array)은:값 x가 몇 번 등장했는지를 배열에 저장하는 방식이다.예:cnt[x] = 값 x의 등장 횟수이 방식은 다음 상황에서 강력하다. 데이터 개수는 많지만 값의 범위는 작다 정렬 대신 개수만 세면 된다 합 분포를 만들고 싶다즉 누적합과 직접 같은 개념은 아니지만,배열 기반 카운팅 문제에서 자주 함께 나온다.18. 합성곱(Convolution)은 어디서 연결되는가이것도 현재 메모에 있던 확장 주제다.두 분포 A, B가 있을 때,A에서 하나, B에서 하나 골라 합이 s가 되는 경우의 수는:result[s] = sum(A[i] * B[s - i])처럼 계산된다.이게 합성곱이다.이 개념은 누적합 자체는 아니지만,도수 분포 배열과 결합하여 다음 문제로 자주 이어진다. 두 수의 합 분포 구간 시작점/끝점 분포 FFT/NTT로 확장되는 문제즉 학습 흐름은 보통:누적합 -> 차분 배열 -> 도수 분포 -> 합성곱처럼 넓어질 수 있다.19. 자주 하는 실수1) prefix[0] = 0을 안 둠이 한 칸이 빠지면 인덱스가 매우 불편해진다.2) 구간 합 공식을 헷갈림prefix[r + 1] - prefix[l]를 기준으로 통일하면 덜 헷갈린다.3) int overflow누적합은 금방 커진다.그래서 보통 long을 쓰는 편이 안전하다.4) 2차원에서 포함-배제 부호를 틀림+ - - +형태를 반드시 기억해야 한다.5) 차분 배열에서 r + 1 처리를 빼먹음구간 종료 지점 다음 칸에서 영향을 끊어야 한다.20. 실전 판단 기준아래와 같은 상황이면 누적합을 먼저 떠올리면 된다. 구간 합 쿼리가 많다 부분 배열의 합에 대한 조건이 있다 연속 구간의 합을 빠르게 반복 계산해야 한다 2차원 직사각형 합을 물어본다 부분합의 나머지나 빈도를 세는 문제다그리고 다음도 같이 연결해서 생각하면 좋다. 구간 업데이트가 많다 -> 차분 배열 값 범위가 작다 -> 도수 분포 배열 두 분포의 합 결과가 중요하다 -> 합성곱21. 시험장용 최소 암기 버전누적합:prefix[i] = 앞에서부터 누적한 합1차원 구간 합:prefix[r + 1] - prefix[l]2차원 구간 합:P[x2][y2] - P[x1-1][y2] - P[x2][y1-1] + P[x1-1][y1-1]장점:전처리 O(N)구간 합 O(1)확장:누적합 + HashMap누적합 + 나머지차분 배열22. 최종 요약누적합은 다음 문장으로 정리할 수 있다.앞에서부터의 합을 미리 저장해 두고구간 합을 빠르게 계산하는 기법핵심만 다시 압축하면: 여러 구간 합 쿼리를 빠르게 처리할 수 있다 prefix[0] = 0 형태가 가장 실전적이다 1차원, 2차원 모두 매우 자주 나온다 부분합의 관계로 바꾸면 개수 세기 문제까지 확장된다 차분 배열은 구간 업데이트 쪽의 짝 개념이다문제를 보면 먼저 이 질문을 하면 된다.이 구간 합을 매번 다시 더하지 않고미리 저장해 둔 정보로 바로 계산할 수 있는가?답이 예라면 누적합일 가능성이 높다.
- MST Algorithm algorithm mst kruskal prim MST (Minimum Spanning Tree) MST (Minimum Spanning Tree)최소 신장 트리(MST)는 그래프의 모든 정점을 연결하면서 간선 가중치의 합이 최소인 트리다.한 줄로 요약하면 다음과 같다.N개 정점을 N-1개 간선으로 연결하는가중치 합 최소의 트리단, 이 정의는 그래프가 연결되어 있을 때만 성립한다.그래프가 연결되어 있지 않다면 MST는 존재하지 않고,대신 각 연결 요소별 최소 신장 포레스트(Minimum Spanning Forest)를 구하게 된다.1. 언제 쓰는가 상황 이유 모든 도시를 최소 비용으로 연결 전형적인 MST 네트워크 케이블 최소 비용 연결 비용 최소화 간선을 제거하며 최소 비용 유지 MST 성질 활용 클러스터링 가장 비싼 간선 K-1개 제거 → K개 그룹 2. 신장 트리란신장 트리(Spanning Tree)는:1. 그래프의 모든 정점을 포함하고2. 사이클이 없으며3. 연결되어 있는 부분 그래프정점이 N개면 간선은 정확히 N-1개다.graph LR subgraph "원본 그래프" A1["1"] --- B1["2"] A1 --- C1["3"] B1 --- C1 B1 --- D1["4"] C1 --- D1 end subgraph "MST" A2["1"] --- B2["2"] A2 --- C2["3"] B2 --- D2["4"] end왼쪽 그래프는 연결은 되어 있지만 사이클이 있고,오른쪽은 모든 정점을 유지하면서 불필요한 간선만 제거한 최소 연결 구조다.3. Kruskal 알고리즘Kruskal은 간선을 가중치 기준 오름차순 정렬한 뒤, 사이클을 만들지 않는 간선만 선택하는 그리디 알고리즘이다.핵심 아이디어1. 모든 간선을 가중치 기준 오름차순 정렬2. 가장 가벼운 간선부터 선택3. 사이클이 생기면 건너뜀 (Union-Find로 판단)4. N-1개 간선을 선택하면 종료static int[] parent, rank;int find(int x) { if (parent[x] != x) parent[x] = find(parent[x]); return parent[x];}boolean union(int a, int b) { a = find(a); b = find(b); if (a == b) return false; if (rank[a] < rank[b]) { int t = a; a = b; b = t; } parent[b] = a; if (rank[a] == rank[b]) rank[a]++; return true;}long kruskal(int n, int[][] edges) { // edges[i] = {u, v, weight} Arrays.sort(edges, (a, b) -> Integer.compare(a[2], b[2])); parent = new int[n + 1]; rank = new int[n + 1]; for (int i = 1; i <= n; i++) parent[i] = i; long totalWeight = 0; int edgeCount = 0; for (int[] edge : edges) { int u = edge[0], v = edge[1], w = edge[2]; if (union(u, v)) { totalWeight += w; edgeCount++; if (edgeCount == n - 1) break; } } if (edgeCount != n - 1) { throw new IllegalArgumentException("그래프가 연결되어 있지 않아 MST가 존재하지 않습니다."); } return totalWeight;}시간 복잡도: O(E log E) (정렬이 지배적)손 계산 예시정점: {1, 2, 3, 4}간선 (정렬 후): (1,2,1) (1,3,2) (2,4,3) (3,4,4) (2,3,5)1단계: (1,2,1) → 선택, union(1,2) ✓2단계: (1,3,2) → 선택, union(1,3) ✓3단계: (2,4,3) → 선택, union(2,4) ✓4단계: 3개 간선 선택 완료 (N-1 = 3)MST 가중치 합 = 1 + 2 + 3 = 6flowchart TD A["간선을 가중치 순 정렬"] --> B["가장 싼 간선 선택"] B --> C{"사이클 발생?<br/>(find(u) == find(v))"} C -->|Yes| D["건너뛰기"] C -->|No| E["MST에 추가, union(u,v)"] D --> F{"N-1개 간선 선택 완료?"} E --> F F -->|No| B F -->|Yes| G["MST 완성"]Kruskal의 핵심은 매번 가장 싼 간선을 보더라도사이클만 만들지 않으면 전체 최적해를 해치지 않는다는 점이다.4. Prim 알고리즘Prim은 하나의 정점에서 시작하여, 현재 트리에 연결된 간선 중 가장 가벼운 것을 선택하는 알고리즘이다.핵심 아이디어1. 시작 정점을 트리에 포함2. 트리에 연결된 간선 중 가장 가벼운 것을 선택3. 선택한 간선의 반대 쪽 정점을 트리에 포함4. N-1개 간선을 선택하면 종료PriorityQueue 방식long prim(int n, List<int[]>[] graph) { // graph[u] = list of {v, weight} boolean[] visited = new boolean[n + 1]; PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> Integer.compare(a[1], b[1])); visited[1] = true; for (int[] edge : graph[1]) { pq.offer(edge); } long totalWeight = 0; int edgeCount = 0; while (!pq.isEmpty() && edgeCount < n - 1) { int[] cur = pq.poll(); int v = cur[0], w = cur[1]; if (visited[v]) continue; visited[v] = true; totalWeight += w; edgeCount++; for (int[] edge : graph[v]) { if (!visited[edge[0]]) { pq.offer(edge); } } } if (edgeCount != n - 1) { throw new IllegalArgumentException("그래프가 연결되어 있지 않아 MST가 존재하지 않습니다."); } return totalWeight;}시간 복잡도: O(E log V) (PriorityQueue 사용 시)Prim 알고리즘 흐름도flowchart TD A["아무 정점에서 시작"] --> B["MST 집합에 추가"] B --> C{"MST 집합 = 전체 정점?"} C -->|Yes| G["MST 완성"] C -->|No| D["MST 집합에서 비-MST 정점으로 가는<br>최소 가중치 간선 탐색"] D --> E["해당 간선과 정점을 MST에 추가"] E --> CPrim은 “간선 전체를 정렬해 놓고 고르는 방식”이 아니라,현재까지 만든 트리의 바깥으로 나가는 간선 중 가장 싼 것만 계속 확장해 나가는 방식이다.손 계산 예시정점: {1, 2, 3, 4}인접 리스트: 1: (2,1), (3,2) 2: (1,1), (3,5), (4,3) 3: (1,2), (2,5), (4,4) 4: (2,3), (3,4)시작: 정점 1PQ: [(2,1), (3,2)]1단계: poll (2,1) → 정점 2 방문, PQ에 (3,5),(4,3) 추가 totalWeight = 12단계: poll (3,2) → 정점 3 방문, PQ에 (4,4) 추가 totalWeight = 33단계: poll (4,3) → 정점 4 방문 totalWeight = 64단계: 3개 간선 선택 완료MST 가중치 합 = 65. Kruskal vs Prim 비교 Kruskal Prim 접근 방식 간선 기반 (전체 정렬) 정점 기반 (확장) 자료구조 Union-Find PriorityQueue 시간 복잡도 O(E log E) O(E log V) 유리한 경우 간선이 적은 희소 그래프 간선이 많은 밀집 그래프 구현 난이도 Union-Find만 있으면 쉬움 약간 더 복잡 코테에서는 Kruskal이 더 자주 쓰인다. Union-Find 구현이 단순하고 재활용할 수 있기 때문이다.6. MST의 핵심 성질1) Cut Property임의의 컷(두 그룹으로 나누기)에서가중치가 유일하게 가장 작은 간선은 모든 MST에 포함된다.동률이 있다면 최소 간선들 중 적어도 하나는 어떤 MST에 포함된다.2) Cycle Property임의의 사이클에서가중치가 유일하게 가장 큰 간선은 어떤 MST에도 포함되지 않는다.동률이 있다면 그 최대 간선 중 일부는 MST에 들어갈 수도 있다.3) 유일성모든 간선 가중치가 다르면 MST는 유일하다.7. 클러스터링 문제에의 응용N개 점을 K개 그룹으로 나누되, 그룹 간 거리가 최대가 되게 하려면:1. MST를 구한다2. MST에서 가장 비싼 간선 K-1개를 제거한다3. K개의 연결 컴포넌트가 클러스터가 된다이것은 Kruskal을 변형하면 된다.N-1개가 아니라 N-K개 간선만 선택하면 된다.// K개 클러스터: N-K개 간선만 선택for (int[] edge : edges) { int u = edge[0], v = edge[1], w = edge[2]; if (union(u, v)) { edgeCount++; if (edgeCount == n - k) break; // K개 그룹 }}8. 간선 가중치가 같을 때가중치가 같은 간선이 있으면 MST가 여러 개 존재할 수 있다.문제에서 “MST의 가중치 합”을 물으면 어떤 MST든 합은 같으므로 상관없다.그러나 “MST의 개수”를 물으면 별도의 분석이 필요하다.9. BOJ/코테 빈출 유형 유형 설명 기본 MST 주어진 그래프에서 MST 가중치 합 구하기 조건부 MST 특정 간선을 반드시 포함/제외하는 MST 클러스터링 MST 변형으로 K개 그룹 나누기 네트워크 연결 이미 연결된 간선이 있는 상태에서 추가 연결 좌표 MST 2차원 좌표점들의 MST (보통 맨해튼 거리) 10. 자주 하는 실수1) Union-Find 초기화를 안 함for (int i = 1; i <= n; i++) parent[i] = i;이걸 빠뜨리면 전부 0으로 같은 그룹 취급된다.2) 간선 개수 체크를 안 함N-1개 간선이 선택되지 않으면 그래프가 비연결이다.이때 MST는 존재하지 않는다.if (edgeCount < n - 1) { // 그래프가 연결되지 않음}3) 양방향 간선을 한 번만 넣음Kruskal에서는 간선 리스트 기반이므로 (u, v, w) 한 번만 넣으면 된다.Prim에서는 인접 리스트에 양방향으로 넣어야 한다.4) 1-indexed vs 0-indexed 혼동정점 번호가 1부터 시작하면 배열도 n+1로 만들자.11. 시험장용 최소 암기 버전Kruskal:1. 간선 가중치 정렬2. Union-Find로 사이클 체크3. N-1개 간선 선택Prim:1. 시작 정점 방문2. PQ에서 최소 가중치 간선 poll3. 미방문 정점이면 추가Union-Find:find(x): 경로 압축union(a,b): 랭크 기준 합치기클러스터링:Kruskal에서 N-K개만 선택12. 최종 요약MST는 다음 문장으로 정리할 수 있다.모든 정점을 최소 비용으로 연결하는 트리Kruskal = 간선 정렬 + Union-FindPrim = PQ로 확장문제를 보면 이 질문을 하면 된다."모든 정점을 연결하되 비용을 최소로 해야 하는가?"→ 그렇다면 MST다Kruskal이 Union-Find만 있으면 구현이 간단하므로,코테에서는 Kruskal을 기본으로 준비하면 된다.
- Math Algorithm algorithm math number-theory Math / Number Theory Math / Number Theory수학 및 정수론은 코딩테스트에서 반복적으로 출제되는 기초 수학 개념과 공식을 다룬다.한 줄로 요약하면 다음과 같다.GCD, 소수 판별, 모듈러 연산, 조합론 등알고리즘의 기반이 되는 수학 도구들1. 언제 쓰는가 상황 관련 개념 최대공약수 / 최소공배수 구하기 유클리드 호제법 소수 판별, 소수 목록 생성 에라토스테네스의 체 큰 수의 연산에서 나머지 출력 모듈러 연산 경우의 수 계산 (nCr, nPr) 조합론 + 모듈러 역원 거듭제곱 빠르게 계산 분할 정복 거듭제곱 약수 개수, 합 구하기 약수 열거 2. 유클리드 호제법 (GCD)최대공약수를 구하는 가장 효율적인 방법이다.핵심 아이디어:GCD(a, b) = GCD(b, a % b)a % b == 0이면 b가 GCDint gcd(int a, int b) { while (b != 0) { int temp = b; b = a % b; a = temp; } return a;}재귀 버전int gcd(int a, int b) { return b == 0 ? a : gcd(b, a % b);}최소공배수 (LCM)int lcm(int a, int b) { return a / gcd(a, b) * b; // overflow 방지: 나누기를 먼저}손 계산 예시GCD(48, 18)= GCD(18, 48 % 18) = GCD(18, 12)= GCD(12, 18 % 12) = GCD(12, 6)= GCD(6, 12 % 6) = GCD(6, 0)= 6flowchart LR A["GCD(48, 18)"] --> B["GCD(18, 12)"] B --> C["GCD(12, 6)"] C --> D["GCD(6, 0)"] D --> E["답: 6"] A -.- A1["48 % 18 = 12"] B -.- B1["18 % 12 = 6"] C -.- C1["12 % 6 = 0"]나머지가 0이 되는 순간 계산이 끝나고,그 직전의 나누는 수가 최대공약수라는 점이 유클리드 호제법의 핵심이다.3. 소수 판별단일 수 판별: O(√N)boolean isPrime(int n) { if (n < 2) return false; for (int i = 2; (long) i * i <= n; i++) { if (n % i == 0) return false; } return true;}핵심: √N까지만 확인하면 된다.코드에서는 overflow를 피하려고 (long) i * i <= n처럼 쓰면 안전하다.N = 36이면 6까지만 보면 되는 이유는,약수 쌍 (2, 18), (3, 12), (4, 9), (6, 6)에서 한쪽은 반드시 √N 이하이기 때문이다.4. 에라토스테네스의 체범위 내 모든 소수를 구하는 알고리즘이다.핵심 아이디어:2부터 시작하여 소수의 배수를 모두 지운다→ 남은 수가 소수boolean[] sieve(int maxN) { boolean[] isComposite = new boolean[maxN + 1]; isComposite[0] = isComposite[1] = true; for (int i = 2; (long) i * i <= maxN; i++) { if (!isComposite[i]) { for (long j = (long) i * i; j <= maxN; j += i) { isComposite[(int) j] = true; } } } return isComposite;}시간 복잡도: O(N log log N) ≈ 사실상 O(N)에 가깝다.손 계산 예시 (N = 30)초기: 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30i=2: 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30 제거i=3: 9, 15, 21, 27 제거i=5: 25 제거남은 소수: 2, 3, 5, 7, 11, 13, 17, 19, 23, 29flowchart TD A["배열 0..N 초기화 (모두 소수 후보)"] --> B["i = 2"] B --> C{"i * i ≤ N?"} C -->|Yes| D{"i가 소수?"} D -->|Yes| E["i*i, i*i+i, i*i+2i, ... 합성수 표시"] D -->|No| F["i++"] E --> F F --> C C -->|No| G["표시 안 된 수 = 소수"]여기서 i * i부터 시작하는 이유는,2i, 3i, …, (i-1)i 같은 배수는 이미 더 작은 소수 단계에서 지워졌기 때문이다.구현할 때는 i * i가 int 범위를 넘을 수 있으므로위 코드처럼 (long) i * i로 계산해 두면 더 안전하다.5. 모듈러 연산코테에서 "답을 1,000,000,007로 나눈 나머지를 출력하라" 같은 문제가 매우 많다.기본 성질(a + b) % M = ((a % M) + (b % M)) % M(a - b) % M = ((a % M) - (b % M) + M) % M(a * b) % M = ((a % M) * (b % M)) % M뺄셈에서 + M하는 이유: 음수 방지.나눗셈은 직접 안 된다(a / b) % M ≠ ((a % M) / (b % M)) % M대신 모듈러 역원을 사용해야 한다.6. 분할 정복 거듭제곱$a^n \mod M$을 O(log N)에 계산한다.핵심 아이디어:a^n = (a^(n/2))^2 (n이 짝수)a^n = a * (a^(n/2))^2 (n이 홀수)long power(long base, long exp, long mod) { long result = 1; base %= mod; while (exp > 0) { if ((exp & 1) == 1) { result = result * base % mod; } base = base * base % mod; exp >>= 1; } return result;}손 계산 예시2^10 mod 1000= (2^5)^2 mod 1000= (2 * (2^2)^2)^2 mod 1000= (2 * 16)^2 mod 1000= 32^2 mod 1000= 1024 mod 1000= 24graph TD A["2¹⁰"] --> B["(2⁵)²"] B --> C["(2 × 2⁴)²"] C --> D["(2 × (2²)²)²"] D --> E["(2 × 16)² = 32²"] E --> F["1024 mod 1000 = 24"] style F fill:#ccffcc,stroke:#333이렇게 지수를 반씩 줄이므로 곱셈 횟수가 O(log N)이 된다.7. 모듈러 역원$\frac{a}{b} \mod M$을 계산하려면 $b$의 모듈러 역원 $b^{-1}$을 구해야 한다.페르마 소정리: M이 소수이고 b % M != 0이면\[b^{-1} \equiv b^{M-2} \pmod{M}\]왜 이게 성립하는가? 페르마 소정리에 의해 $b^{M-1} \equiv 1 \pmod{M}$이다.즉 $b \times b^{M-2} \equiv 1 \pmod{M}$이므로 $b^{M-2}$가 곱셈의 역원이다.반대로 gcd(b, M) != 1이면 역원이 아예 존재하지 않을 수도 있으므로 이 공식을 그대로 쓰면 안 된다.즉 power(b, M - 2, M) 방식은 다음 조건이 붙는다. M이 소수 b % M != 0만약 M이 소수가 아니라면: gcd(b, M) = 1일 때만 역원이 존재하고 이때는 확장 유클리드 알고리즘으로 구하는 방식이 일반적이다코테에서는 보통 MOD = 1_000_000_007 같은 소수 모듈러가 자주 나오므로페르마 소정리를 쓰는 버전을 먼저 익히면 된다.long modInverse(long b, long mod) { return power(b, mod - 2, mod);}// a / b mod Mlong divMod(long a, long b, long mod) { return a % mod * modInverse(b, mod) % mod;}8. 조합론 (nCr)파스칼의 삼각형: O(N²)long[][] comb = new long[N + 1][N + 1];for (int i = 0; i <= N; i++) { comb[i][0] = 1; for (int j = 1; j <= i; j++) { comb[i][j] = (comb[i - 1][j - 1] + comb[i - 1][j]) % MOD; }}점화식: $\binom{n}{r} = \binom{n-1}{r-1} + \binom{n-1}{r}$파스칼 삼각형 (n=0~4): 1 C(0,0) = 1 1 1 C(1,0)=1 C(1,1)=1 1 2 1 C(2,1) = C(1,0)+C(1,1) = 2 1 3 3 1 C(3,2) = C(2,1)+C(2,2) = 3 1 4 6 4 1 C(4,2) = C(3,1)+C(3,2) = 6이 방법은 N이 작을 때 (N ≤ 2000 정도) 사용한다.팩토리얼 + 역원: O(N)N이 큰 경우 (N ≤ 10^6) 팩토리얼을 전처리해서 사용한다.\[\binom{n}{r} = \frac{n!}{r! \cdot (n-r)!} \mod M\]static final long MOD = 1_000_000_007;long[] fact, invFact;void precompute(int n) { fact = new long[n + 1]; invFact = new long[n + 1]; fact[0] = 1; for (int i = 1; i <= n; i++) { fact[i] = fact[i - 1] * i % MOD; } invFact[n] = power(fact[n], MOD - 2, MOD); for (int i = n - 1; i >= 0; i--) { invFact[i] = invFact[i + 1] * (i + 1) % MOD; }}long nCr(int n, int r) { if (r < 0 || r > n) return 0; return fact[n] % MOD * invFact[r] % MOD * invFact[n - r] % MOD;}핵심 트릭: 역팩토리얼을 뒤에서부터 채운다.invFact[n] = (n!)^(M-2)invFact[i] = invFact[i+1] * (i+1) (왜? invFact[i] = 1/i! = (i+1)/((i+1)!) = (i+1) * invFact[i+1])9. 약수 열거N의 모든 약수를 구하는 방법이다.List<Integer> getDivisors(int n) { List<Integer> divisors = new ArrayList<>(); for (int i = 1; (long) i * i <= n; i++) { if (n % i == 0) { divisors.add(i); if (i != n / i) { divisors.add(n / i); } } } return divisors;}시간 복잡도: O(√N)10. 소인수분해Map<Integer, Integer> factorize(int n) { Map<Integer, Integer> factors = new HashMap<>(); for (int i = 2; (long) i * i <= n; i++) { while (n % i == 0) { factors.put(i, factors.getOrDefault(i, 0) + 1); n /= i; } } if (n > 1) { factors.put(n, factors.getOrDefault(n, 0) + 1); } return factors;}이것도 O(√N)이다.11. 자주 출제되는 수학 패턴1) 두 수의 GCD/LCM직접 유클리드 호제법 적용.2) N개 수의 GCD/LCMint gcdAll = arr[0];long lcmAll = arr[0];for (int i = 1; i < n; i++) { gcdAll = gcd(gcdAll, arr[i]); lcmAll = lcm(lcmAll, arr[i]); // overflow 주의}3) 경우의 수 mod 출력팩토리얼 전처리 + nCr 사용.4) “나누어 떨어지는 수의 개수”약수 열거 또는 에라토스테네스 응용.5) 거듭제곱 mod분할 정복 거듭제곱.12. 주의사항1) int overflowGCD는 괜찮지만, LCM은 쉽게 overflow된다.long을 쓰자.long lcm = (long) a / gcd(a, b) * b;2) 모듈러 연산에서 뺄셈(a - b) % MOD // 음수 가능!((a - b) % MOD + MOD) % MOD // 안전3) nCr에서 r > n 체크r > n이면 0이다. 이걸 안 하면 배열 인덱스 오류가 난다.4) 나눗셈에 모듈러 직접 적용나눗셈은 모듈러 역원을 써야 한다. 직접 나누면 틀린다.13. 자주 하는 실수1) a * b % MOD에서 중간 곱셈 overflowa와 b가 둘 다 int 범위이면 곱셈이 long 범위를 넘을 수 있다.long으로 캐스팅하자.long result = (long) a * b % MOD;2) 체를 칠 때 j = i * 2가 아니라 j = i * ii * (i-1) 이하는 이미 이전 소수에서 지워졌다.j = i * i부터 시작해야 효율적이다.3) GCD에 음수를 넣음Math.abs()로 양수를 보장하자.14. 시험장용 최소 암기 버전GCD:int gcd(int a, int b) { return b == 0 ? a : gcd(b, a % b); }LCM:a / gcd(a,b) * b (나누기 먼저)소수 판별:for (i = 2; i*i <= n; i++)에라토스테네스:for (i = 2; i*i <= n) → for (j = i*i; j <= n; j += i)거듭제곱 mod:while (exp > 0) { if (odd) result *= base; base *= base; exp >>= 1; }모듈러 역원 (M이 소수, b % M != 0):b^(M-2) mod MnCr mod:fact[] 전처리 + invFact[] 역순 구성모듈러 뺄셈:(a - b % MOD + MOD) % MOD15. 최종 요약수학/정수론은 다음 문장으로 정리할 수 있다.GCD, 소수, 모듈러 연산, 조합론은코테에서 반복 출제되는 필수 수학 도구문제에서 "답을 10^9 + 7로 나눈 나머지", "약수", "소수", "경우의 수" 같은 키워드가 나오면이 도구들을 떠올리면 된다.
- KMP Algorithm algorithm kmp KMP KMPKMP는 문자열에서 패턴을 효율적으로 찾는 알고리즘이다.한 줄로 요약하면 다음과 같다.실패했을 때도 이미 맞춘 접두사 정보를 재사용해서비교를 다시 하지 않는 문자열 탐색 알고리즘즉 KMP의 핵심은:다시 처음부터 비교하지 않는다는 점이다.1. 언제 쓰는가아래 상황이면 KMP를 떠올릴 수 있다. 문자열에서 패턴 찾기 부분 문자열 등장 여부 패턴 등장 횟수 접두사 / 접미사 정보가 중요함 같은 문자열 비교를 반복하면 느림대표 문제: 문자열 내 패턴 출현 위치 찾기 반복 패턴 분석 광고, 문자열 제곱, 주기 문제 일부2. 왜 일반 탐색은 비효율적인가패턴을 찾다가 중간에 틀리면,단순 탐색은 시작점을 한 칸 옮기고 다시 비교한다.즉 이미 맞춘 문자들도 또 비교하게 된다.예: text = ababababca pattern = abababca이런 경우 앞부분이 많이 겹치므로,단순 탐색은 중복 비교가 많아진다.3. 핵심 아이디어KMP는 패턴 내부의 접두사 / 접미사 정보를 이용해 점프한다.즉 현재까지 맞춘 문자 중 일부는,다음 비교에서도 그대로 활용할 수 있다.flowchart TD A["문자 비교"] --> B{"불일치 발생"} B -->|No| A B -->|Yes| C["pi 테이블 사용"] C --> D["패턴 인덱스 점프"] D --> A즉 KMP는 불일치가 나도 text 인덱스를 크게 되돌리지 않고,pattern 쪽 포인터만 효율적으로 이동시킨다.4. pi 배열이란 무엇인가pi[i]는 패턴의 0..i 구간에서,접두사이면서 접미사인 것의 최대 길이다.말이 어려우면 이렇게 보면 된다.앞부분과 뒷부분이 얼마나 겹치나?예:pattern = ababapi = 0 0 1 2 3왜냐하면: a -> 겹침 0 ab -> 겹침 0 aba -> a 겹침 -> 1 abab -> ab 겹침 -> 2 ababa -> aba 겹침 -> 3이 감각이 가장 중요하다.5. pi 배열을 왜 쓰는가패턴을 맞추다가 j 위치에서 실패했다고 하자.그때 패턴의 앞부분 중 일부는 이미 text와 맞았다는 사실을 알고 있다.따라서 완전히 처음으로 돌아가지 않고,j = pi[j - 1]로 점프할 수 있다.즉 이미 맞춘 접두사 정보를 재사용하는 것이다.6. pi 배열 만들기int[] getPi(String p) { int n = p.length(); int[] pi = new int[n]; int j = 0; for (int i = 1; i < n; i++) { while (j > 0 && p.charAt(i) != p.charAt(j)) { j = pi[j - 1]; } if (p.charAt(i) == p.charAt(j)) { pi[i] = ++j; } } return pi;}해석 i: 새로 확인하는 위치 j: 현재까지 맞춘 접두사 길이 mismatch가 나면 j = pi[j - 1]로 점프 match가 나면 접두사 길이를 1 늘린다7. pi 배열 손으로 만들어 보기패턴:ababaca인덱스별로 보면: a -> 0 ab -> 0 aba -> a 겹침 -> 1 abab -> ab 겹침 -> 2 ababa -> aba 겹침 -> 3 ababac -> 겹침 없음 -> 0으로 떨어짐 ababaca -> a 겹침 -> 1즉:pi = [0, 0, 1, 2, 3, 0, 1]인덱스: 0 1 2 3 4 5 6문자: a b a b a c api: 0 0 1 2 3 0 1 │ ↗ │ └──────── 접두사 a와 접미사 a가 겹침 ────────┘ "abab" → 접두사 "ab" = 접미사 "ab" → pi[3] = 2 "ababa" → 접두사 "aba" = 접미사 "aba" → pi[4] = 38. KMP 탐색List<Integer> kmp(String text, String pattern) { int[] pi = getPi(pattern); List<Integer> result = new ArrayList<>(); int j = 0; for (int i = 0; i < text.length(); i++) { while (j > 0 && text.charAt(i) != pattern.charAt(j)) { j = pi[j - 1]; } if (text.charAt(i) == pattern.charAt(j)) { if (j == pattern.length() - 1) { result.add(i - pattern.length() + 2); // 1-based index j = pi[j]; } else { j++; } } } return result;}이 코드를 읽을 때 핵심은 i와 j의 역할을 분리해서 보는 것이다. i: text를 한 번만 왼쪽에서 오른쪽으로 훑는 포인터 j: pattern에서 현재 몇 글자까지 맞았는지 나타내는 포인터즉 KMP는 text를 다시 읽는 것이 아니라,pattern 쪽 정렬만 다시 맞춘다고 생각하면 이해가 쉬워진다.KMP 매칭 과정 예시text: a b a b a c d a b a b a c apattern: a b a b a c a ↑ ↑ ↑ ↑ ↑ ✓ j=5까지 매치, j=6에서 mismatch (d ≠ a)→ j = pi[5-1] = pi[4] = 3 (aba 접두사가 겹침)→ pattern을 2칸 밀어서 다시 비교:text: a b a b a c d a b a b a c a a b a b a c a ↑ 여기(j=3)부터 이어서 비교 이미 매치된 "aba"를 다시 비교하지 않는다이것이 KMP가 O(N + M)인 이유다. text의 i는 절대 뒤로 가지 않는다.9. 탐색을 어떻게 이해하면 좋은가i는 text를 한 번만 왼쪽에서 오른쪽으로 본다.j는 pattern에서 현재까지 맞춘 길이를 뜻한다. match면 j++ mismatch면 j = pi[j - 1] 완전 매칭이면 정답 기록 후 j = pi[j]즉 text 인덱스를 되돌리지 않고,pattern 쪽만 효율적으로 재배치한다.이 점 때문에 KMP는 단순 탐색보다 훨씬 빠르다.앞에서 힘들게 맞춘 정보를 버리지 않기 때문이다.mismatch가 났을 때 j가 어떻게 줄어드나예를 들어 패턴이 ababaca이고,현재 j = 5까지 맞춘 상태에서 다음 문자가 틀렸다고 하자.KMP는 다시 처음으로 가지 않고pi를 따라 “지금까지 맞춘 접두사 중 다음 후보”로 바로 이동한다.flowchart LR A["j = 5"] --> B["pi[4] = 3"] B --> C{"여전히 불일치?"} C -->|Yes| D["pi[2] = 1"] D --> E{"여전히 불일치?"} E -->|Yes| F["0"]핵심은 text를 다시 읽는 것이 아니라,패턴 내부의 겹침만 따라 내려간다는 점이다.그래서 i는 뒤로 가지 않고도다음 유효한 정렬 위치를 곧바로 찾을 수 있다.10. 매칭 완료 후 왜 j = pi[j]인가패턴 하나를 찾았다고 끝이 아닐 수 있다.겹치는 다음 패턴도 찾아야 할 수 있다.예: text = aaaaa pattern = aaa정답은 시작 위치가 1, 2, 3이다.이런 겹침 매칭을 놓치지 않으려면,매칭 완료 후에도 접두사 정보를 이용해 j를 옮겨야 한다.즉 j = pi[j]는 단순한 코드 한 줄이 아니라,“다음 후보 시작점을 접두사 정보로 바로 맞춘다”는 뜻이다.11. 시간 복잡도 pi 배열 생성: O(M) 탐색: O(N)즉 전체:O(N + M)이다.KMP가 강력한 이유는 중복 비교를 제거해 선형 시간에 동작한다는 점이다.12. 자주 하는 실수1) mismatch 시 j = pi[j - 1]를 안 함KMP의 핵심을 놓치는 것이다.2) 패턴 매칭 완료 후 j = pi[j]를 빼먹음겹치는 패턴을 놓칠 수 있다.3) 인덱스 0-based / 1-based 혼동문제 출력 형식에 따라 시작 위치 계산을 주의해야 한다.4) pi 배열 의미를 외우기만 하고 이해하지 못함접두사 / 접미사 겹침이라는 감각이 있어야 응용이 된다.13. 시험장용 최소 암기 버전KMP:문자열 패턴 탐색 O(N + M)핵심:pi 배열불일치 시 j = pi[j - 1]pi 의미:접두사이면서 접미사인 최대 길이14. 최종 요약KMP는 다음 문장으로 정리할 수 있다.패턴의 접두사 / 접미사 정보를 이용해중복 비교를 피하는 선형 문자열 탐색 알고리즘문제를 보면 먼저 이 질문을 하면 된다.문자열 비교 실패 후,이미 맞춘 정보 중 재사용할 수 있는 부분이 있는가?이 감각이 KMP의 핵심이다.
- Implementation Algorithm algorithm implementation simulation Implementation & Simulation Implementation & Simulation구현(Implementation)은 문제에서 요구하는 동작을 그대로 코드로 옮기는 유형이다.한 줄로 요약하면 다음과 같다.알고리즘보다 정확한 시뮬레이션이 핵심인 문제특별한 자료구조나 알고리즘 없이도 풀 수 있지만,조건이 많고 실수가 나기 쉬워서 체계적인 접근이 중요하다.1. 언제 나오는가문제에서 아래 표현이 보이면 구현/시뮬레이션을 의심하면 된다. 격자 위에서 이동 방향 회전 주사위, 톱니바퀴, 뱀 조건이 많고 복잡함 “규칙대로 반복” 시간 순서대로 처리대표 출제처: 삼성 SW 역량 테스트 카카오 1차 구현 문제 프로그래머스 Lv2~3 구현2. 구현 문제의 핵심 난이도구현 문제가 어려운 이유는 알고리즘이 아니라 다음 세 가지다.1. 조건이 많다2. 예외가 숨어 있다3. 코드가 길어지면 실수가 쌓인다따라서 구현 문제에서는: 문제를 꼼꼼하게 읽고 동작을 단계별로 분리하고 각 단계를 함수로 나누는 것이 가장 중요하다.3. 격자 이동: 방향 배열격자 문제에서 가장 먼저 세팅하는 것이 방향 배열이다.4방향 (상하좌우)int[] dx = {-1, 1, 0, 0};int[] dy = {0, 0, -1, 1};8방향 (대각선 포함)int[] dx = {-1, -1, -1, 0, 0, 1, 1, 1};int[] dy = {-1, 0, 1, -1, 1, -1, 0, 1};이동은 항상 다음 형태로 처리한다.for (int d = 0; d < 4; d++) { int nx = x + dx[d]; int ny = y + dy[d]; if (nx < 0 || nx >= N || ny < 0 || ny >= M) continue; // 범위 안이면 처리}4. 방향 회전시뮬레이션에서 방향을 다루는 문제는 매우 자주 나온다.방향 인덱스 관례보통 다음처럼 정한다.0: 상 (북)1: 우 (동)2: 하 (남)3: 좌 (서)flowchart LR subgraph "방향 인덱스" D0["0: 상 ↑"] D1["1: 우 →"] D2["2: 하 ↓"] D3["3: 좌 ←"] end D0 -->|"시계 90°"| D1 D1 -->|"시계 90°"| D2 D2 -->|"시계 90°"| D3 D3 -->|"시계 90°"| D0시계 방향 90도 회전dir = (dir + 1) % 4;반시계 방향 90도 회전dir = (dir + 3) % 4;180도 회전dir = (dir + 2) % 4;이 패턴은 외워야 한다.특히 반시계를 (dir - 1 + 4) % 4로 써도 맞고, (dir + 3) % 4는 읽기가 더 단순하다.5. 격자 회전격자 자체를 90도 회전하는 문제도 자주 나온다.시계 방향 90도int[][] rotate90(int[][] grid) { int n = grid.length; int m = grid[0].length; int[][] result = new int[m][n]; for (int i = 0; i < n; i++) { for (int j = 0; j < m; j++) { result[j][n - 1 - i] = grid[i][j]; } } return result;}핵심 공식:시계 90도: (i, j) -> (j, N-1-i)반시계 90도: (i, j) -> (M-1-j, i)180도: (i, j) -> (N-1-i, M-1-j)6. 배열 돌리기 (테두리 회전)격자 회전이 배열 전체를 90도 돌리는 것이라면,배열 돌리기는 테두리(링)를 따라 원소를 한 칸씩 밀어내는 문제다.백준 16926번(배열 돌리기 1), 16927번(배열 돌리기 2) 등이 대표적이다.핵심 아이디어N×M 배열에서 테두리(링)의 개수는 min(N, M) / 2개다.┌───────────┐│ 링 0 ││ ┌───────┐ ││ │ 링 1 │ ││ │ ┌───┐ │ ││ │ │ 2 │ │ ││ │ └───┘ │ ││ └───────┘ │└───────────┘flowchart TD A["전체 배열"] --> B["링 개수 = min(N, M) / 2"] B --> C["각 링마다"] C --> D["링의 원소를 1차원으로 추출"] D --> E["R칸만큼 회전"] E --> F["다시 링 위치에 채우기"]k번째 링의 범위시작점: (k, k)끝점: (N-1-k, M-1-k)링의 원소 추출 순서시계 방향으로 테두리를 따라 순서대로 꺼내면 된다.1. 위쪽 변: (k, k) → (k, M-1-k) 왼쪽에서 오른쪽2. 오른쪽 변: (k+1, M-1-k) → (N-1-k, M-1-k) 위에서 아래3. 아래쪽 변: (N-1-k, M-2-k) → (N-1-k, k) 오른쪽에서 왼쪽4. 왼쪽 변: (N-2-k, k) → (k+1, k) 아래에서 위구현 코드void rotateRing(int[][] arr, int N, int M, int R) { int rings = Math.min(N, M) / 2; for (int k = 0; k < rings; k++) { // 1. 링의 원소를 리스트로 추출 List<Integer> list = new ArrayList<>(); // 위쪽 변 for (int j = k; j < M - k; j++) list.add(arr[k][j]); // 오른쪽 변 for (int i = k + 1; i < N - k; i++) list.add(arr[i][M - 1 - k]); // 아래쪽 변 for (int j = M - 2 - k; j >= k; j--) list.add(arr[N - 1 - k][j]); // 왼쪽 변 for (int i = N - 2 - k; i > k; i--) list.add(arr[i][k]); // 2. R칸 회전 (반시계: 앞에서 R개를 뒤로 보내기) int len = list.size(); int r = R % len; // 링 길이로 나머지 List<Integer> rotated = new ArrayList<>(); for (int i = r; i < len; i++) rotated.add(list.get(i)); for (int i = 0; i < r; i++) rotated.add(list.get(i)); // 3. 다시 링 위치에 채우기 int idx = 0; for (int j = k; j < M - k; j++) arr[k][j] = rotated.get(idx++); for (int i = k + 1; i < N - k; i++) arr[i][M - 1 - k] = rotated.get(idx++); for (int j = M - 2 - k; j >= k; j--) arr[N - 1 - k][j] = rotated.get(idx++); for (int i = N - 2 - k; i > k; i--) arr[i][k] = rotated.get(idx++); }}배열 돌리기 주의사항1. R이 링 길이보다 클 수 있다 → R %= len 필수2. 추출 순서와 채우기 순서가 반드시 동일해야 한다3. 반시계 회전이면 앞에서 R개를 뒤로, 시계 회전이면 뒤에서 R개를 앞으로4. 각 링은 독립적이므로 링마다 개별 처리한다배열 돌리기 2 (큰 R 최적화)배열 돌리기 2(백준 16927)에서는 R이 최대 10⁹로 매우 크다.하지만 각 링의 길이만큼 회전하면 원래대로 돌아오므로,R % len으로 실제 회전 횟수를 줄이면 시간 내에 풀 수 있다.링 길이 = 2 * (가로 + 세로) - 4→ 한 바퀴 돌면 원상 복구→ R % 링길이 만큼만 회전7. 시뮬레이션 문제 접근법시뮬레이션은 보통 다음 구조를 따른다.flowchart TD A["입력 파싱 & 초기 상태 세팅"] --> B["매 턴/시간 반복"] B --> C["조건에 따라 상태 변경"] C --> D{"종료 조건?"} D -->|No| B D -->|Yes| E["결과 출력"]실전 팁: 상태를 변수로 명확히 정의한다 매 턴의 동작을 함수 하나로 분리한다 디버깅할 때 매 턴 상태를 출력해 보면 빠르다8. 뱀 게임 같은 시뮬레이션 예시전형적인 시뮬레이션 구조를 요약하면 다음과 같다.1. 초기 위치, 방향 설정2. 매 턴마다: a. 현재 방향으로 한 칸 이동 b. 벽이나 자기 몸이면 종료 c. 사과가 있으면 먹고 꼬리 유지 d. 사과가 없으면 꼬리 줄이기 e. 방향 전환 명령이 있으면 적용3. 반복이런 문제는 Deque로 뱀의 몸을 관리하면 편하다.Deque<int[]> snake = new ArrayDeque<>();snake.offerFirst(new int[]{headX, headY});// 사과가 없으면 꼬리 제거if (!apple) { int[] tail = snake.pollLast(); grid[tail[0]][tail[1]] = 0;}9. 좌표계 주의격자 문제에서 가장 많이 실수하는 부분이 좌표계다.배열 인덱스 vs 수학 좌표배열: (행, 열) = (row, col) → 아래로 갈수록 row 증가수학: (x, y) → 위로 갈수록 y 증가문제가 “위쪽 이동”이라고 하면: 배열 기준이면 row - 1 수학 좌표 기준이면 y + 1문제를 먼저 읽고 좌표 체계를 확인한 뒤,dx, dy 방향 배열을 그에 맞게 설정해야 한다.10. 2차원 배열 복사시뮬레이션에서 상태를 복사해야 하는 경우가 자주 있다.주의: 얕은 복사 함정// 틀림: 1차원 배열 참조만 복사됨int[][] copy = grid.clone();올바른 깊은 복사int[][] copy = new int[N][M];for (int i = 0; i < N; i++) { copy[i] = grid[i].clone();}혹은:int[][] copy = new int[N][M];for (int i = 0; i < N; i++) { System.arraycopy(grid[i], 0, copy[i], 0, M);}11. 구현 문제에서 함수 분리의 중요성구현 문제에서 코드가 100줄이 넘어가면 실수 확률이 급격히 올라간다.다음처럼 동작 단위로 함수를 분리하면 디버깅이 훨씬 쉬워진다.void solve() { init(); // 초기 상태 설정 for (int t = 0; t < T; t++) { move(); // 이동 rotate(); // 회전 spread(); // 확산 clean(); // 제거 } print(); // 결과 출력}각 함수는 하나의 동작만 담당한다.이렇게 하면: 버그가 어느 단계에서 나는지 빠르게 특정할 수 있고 단계별로 중간 상태를 출력하기 쉽다12. 삼성 스타일 문제 패턴삼성 코테에서 자주 나오는 시뮬레이션 패턴을 정리하면 다음과 같다.1) 격자 + BFS/DFS영역을 찾고, 조건에 맞는 영역을 처리2) 격자 + 방향 이동 + 조건 분기로봇이나 물체가 조건에 따라 격자 위를 이동3) 격자 + 확산/복사미세먼지, 바이러스 확산 같은 동시 갱신동시 갱신 문제에서는 반드시:현재 상태를 읽되 새 상태에 기록한다를 지켜야 한다. 같은 배열에서 읽고 쓰면 틀린다.int[][] next = deepCopy(grid);for (int i = 0; i < N; i++) { for (int j = 0; j < M; j++) { // grid에서 읽고 next에 쓴다 }}grid = next;4) 여러 동작의 순서 조합격자 회전 + 중력 + 폭발 + 또 회전각 동작을 함수로 분리하고 순서대로 호출하면 된다.13. 문자열 파싱 구현카카오 스타일 문제에서 자주 나오는 유형이다.특정 포맷의 문자열을 파싱해서 조건대로 처리예를 들어 시간 문자열 "12:30:45"를 초 단위로 바꾸기:String[] parts = time.split(":");int h = Integer.parseInt(parts[0]);int m = Integer.parseInt(parts[1]);int s = Integer.parseInt(parts[2]);int totalSec = h * 3600 + m * 60 + s;파싱 문제는 split, substring, charAt, StringBuilder 네 가지만 알면 거의 다 풀린다.14. 진법 변환구현 문제에서 간간이 나오는 유형이다.10진수 -> N진수String toBaseN(int num, int n) { if (num == 0) return "0"; StringBuilder sb = new StringBuilder(); while (num > 0) { int r = num % n; sb.append(r < 10 ? (char)('0' + r) : (char)('A' + r - 10)); num /= n; } return sb.reverse().toString();}N진수 -> 10진수int toDecimal(String s, int n) { int result = 0; for (char c : s.toCharArray()) { int digit = Character.isDigit(c) ? c - '0' : c - 'A' + 10; result = result * n + digit; } return result;}15. 자주 하는 실수1) 범위 체크를 빼먹음격자 이동에서 nx < 0 || nx >= N 확인은 필수다.2) 방향 인덱스와 dx/dy 매핑 불일치0이 상인데 dx[0] = 1 로 잘못 넣으면 전체가 틀린다3) 동시 갱신을 순차 갱신으로 처리확산이나 이동에서 읽기/쓰기를 같은 배열에 하면 안 된다.4) 깊은 복사를 안 함clone()만으로는 2차원 배열이 복사되지 않는다.5) 문제 조건을 빠뜨림구현 문제는 조건이 5~10개 넘는 경우가 많다.체크리스트를 만들어 두면 좋다.6) 0-indexed와 1-indexed 혼동문제가 1-based이면 배열도 N + 1 크기로 만드는 편이 안전하다.16. 실전 판단 기준아래 신호가 보이면 구현/시뮬레이션이다. 격자 위에서 무언가 움직인다 시간 순서대로 처리한다 알고리즘보다 조건이 많다 “규칙대로 반복 수행” 문제 자체가 게임처럼 생겼다그리고 이런 문제에서는 다음이 가장 중요하다.빠르게 풀기보다 정확하게 풀기17. 시험장용 최소 암기 버전구현/시뮬레이션:문제 조건을 그대로 코드로 옮기기격자 이동:dx, dy 방향 배열 + 범위 체크방향 회전:시계: (dir + 1) % 4반시계: (dir + 3) % 4격자 회전:시계 90도: (i,j) -> (j, N-1-i)동시 갱신:읽는 배열과 쓰는 배열을 분리핵심 습관:동작 단위로 함수 분리18. 최종 요약구현/시뮬레이션은 다음 문장으로 정리할 수 있다.특별한 알고리즘 없이문제의 조건과 규칙을 정확하게 코드로 옮기는 유형핵심만 다시 압축하면: 방향 배열, 범위 체크, 격자 회전 공식은 외워야 한다 동시 갱신 문제는 읽기/쓰기 배열을 반드시 분리한다 코드가 길어질수록 함수 분리가 핵심이다 문제 조건을 체크리스트로 만들면 실수가 줄어든다문제를 보면 먼저 이 질문을 하면 된다.이 문제는 특정 알고리즘이 필요한가,아니면 규칙을 정확히 옮기면 되는가?규칙을 옮기는 것이 핵심이면 구현 문제다.
- Heap Algorithm algorithm heap priority-queue Heap / Priority Queue Heap / Priority Queue힙(Heap)은 가장 크거나 작은 원소를 O(log N)에 꺼낼 수 있는 완전 이진 트리 기반 자료구조다.한 줄로 요약하면 다음과 같다.삽입도 O(log N), 최솟값(또는 최댓값) 추출도 O(log N)Java에서는 PriorityQueue 클래스가 최소 힙으로 구현되어 있다.1. 언제 쓰는가 상황 이유 가장 작은(또는 큰) 원소를 반복적으로 뽑아야 할 때 정렬하면 O(N log N) 한 번이지만, 중간에 삽입이 있으면 정렬 반복은 비효율적 Top-K 문제 K개만 유지하면 O(N log K) 다익스트라 최단 거리 노드를 빠르게 추출 작업 스케줄링 우선순위가 높은 작업 먼저 처리 중앙값 유지 최소 힙 + 최대 힙 조합 2. 핵심 아이디어힙은 완전 이진 트리로, 부모와 자식 사이에 대소 관계가 있다.최소 힙 기준:부모 ≤ 자식→ 루트가 항상 최솟값graph TD A["1"] --> B["3"] A --> C["2"] B --> D["7"] B --> E["5"] C --> F["4"] C --> G["6"] style A fill:#f96,color:#000루트(1)가 항상 최솟값이므로 peek()은 O(1)이다.3. 힙의 핵심 연산삽입 (offer) 완전 이진 트리의 마지막 위치에 삽입 부모와 비교하며 위로 올림 (Sift Up)추출 (poll) 루트(최솟값)를 제거 마지막 원소를 루트로 이동 자식과 비교하며 아래로 내림 (Sift Down)flowchart TD subgraph "offer(x)" A1["마지막 위치에 삽입"] --> A2["Sift Up: 부모보다 작으면 교환"] end subgraph "poll()" B1["루트 제거"] --> B2["마지막 요소를 루트로 이동"] --> B3["Sift Down: 더 작은 자식과 교환"] end즉 삽입은 위로 올리는 과정이고,삭제는 루트에 올라온 값을 아래로 내리는 과정이라는 비대칭을 기억하면 구현이 덜 헷갈린다.4. 배열로 표현하는 힙힙은 배열로 표현할 수 있다 (0-indexed 기준).부모 인덱스: (i - 1) / 2왼쪽 자식: 2 * i + 1오른쪽 자식: 2 * i + 2인덱스: 0 1 2 3 4 5 6값: [1, 3, 2, 7, 5, 4, 6]이 배열이 위의 트리 구조와 동일하다.5. Java PriorityQueue 기본 사용법최소 힙 (기본)PriorityQueue<Integer> minHeap = new PriorityQueue<>();minHeap.offer(5);minHeap.offer(1);minHeap.offer(3);System.out.println(minHeap.peek()); // 1System.out.println(minHeap.poll()); // 1System.out.println(minHeap.poll()); // 3최대 힙PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Collections.reverseOrder());maxHeap.offer(5);maxHeap.offer(1);maxHeap.offer(3);System.out.println(maxHeap.poll()); // 5커스텀 비교 (2차원 배열)// (비용, 노드) 쌍에서 비용 기준 최소 힙PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> Integer.compare(a[0], b[0]));pq.offer(new int[]{10, 1});pq.offer(new int[]{3, 2});pq.offer(new int[]{7, 3});int[] min = pq.poll(); // {3, 2}6. Top-K 문제 패턴가장 큰 K개 → 최소 힙 크기 K 유지int[] topK(int[] arr, int k) { PriorityQueue<Integer> minHeap = new PriorityQueue<>(); for (int x : arr) { minHeap.offer(x); if (minHeap.size() > k) { minHeap.poll(); // 가장 작은 것 제거 } } // minHeap에 가장 큰 K개가 남아 있음 int[] result = new int[k]; for (int i = k - 1; i >= 0; i--) { result[i] = minHeap.poll(); } return result;}시간 복잡도: O(N log K)왜 최소 힙인가?최소 힙에서 항상 가장 작은 값이 위에 있으므로크기가 K를 넘으면 가장 작은 것을 버리면결과적으로 큰 K개만 남는다flowchart LR A["스트림 요소"] --> B["Min-Heap에 삽입 (크기 K)"] B --> C{"heap.size > K?"} C -->|Yes| D["poll()로 최솟값 제거"] C -->|No| B D --> B B --> E["최종 Heap = 상위 K개 최댓값"]여기서 최소 힙의 루트는“현재 상위 K개 후보 중 가장 먼저 버려질 값”이라는 의미를 가진다.그래서 새 값이 들어올 때마다 루트와 비교하면 유지 여부를 빠르게 결정할 수 있다.7. 중앙값 유지 패턴데이터가 계속 추가되면서 중앙값을 유지해야 하는 문제다.핵심 아이디어:왼쪽 절반 → 최대 힙 (maxHeap)오른쪽 절반 → 최소 힙 (minHeap)maxHeap.peek() ≤ minHeap.peek() 유지PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Collections.reverseOrder()); // 왼쪽PriorityQueue<Integer> minHeap = new PriorityQueue<>(); // 오른쪽void addNum(int num) { maxHeap.offer(num); minHeap.offer(maxHeap.poll()); // 최대 힙의 최대를 최소 힙으로 // 크기 균형: maxHeap >= minHeap if (maxHeap.size() < minHeap.size()) { maxHeap.offer(minHeap.poll()); }}int getMedian() { return maxHeap.peek(); // 홀수 개 중앙값, 짝수 개라면 lower median}graph LR subgraph "maxHeap (왼쪽 절반)" M1["작은 값 절반 저장"] end subgraph "minHeap (오른쪽 절반)" M2["큰 값 절반 저장"] end M1 -- "maxHeap.peek() ≤ minHeap.peek()" --> M2 M1 -- "중앙값 = maxHeap.peek()" --> R["결과"]두 힙의 크기를 같거나 maxHeap이 1개 더 많게 유지하면,홀수 개 입력에서는 maxHeap.peek()가 중앙값이 되고짝수 개 입력에서는 maxHeap.peek()가 lower median이 된다.문제에서 “짝수일 때 두 중앙값 평균”을 요구하면 maxHeap.peek()와 minHeap.peek()를 함께 써야 한다.8. 다익스트라에서의 힙다익스트라 알고리즘에서 힙은 핵심이다.PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> Integer.compare(a[0], b[0]));pq.offer(new int[]{0, start}); // {거리, 노드}while (!pq.isEmpty()) { int[] cur = pq.poll(); int dist = cur[0], node = cur[1]; if (dist > d[node]) continue; // 이미 더 짧은 경로로 방문됨 for (int[] edge : graph[node]) { int next = edge[0], weight = edge[1]; if (d[node] + weight < d[next]) { d[next] = d[node] + weight; pq.offer(new int[]{d[next], next}); } }}if (dist > d[node]) continue; 이 한 줄이 핵심이다.PQ에 같은 노드가 여러 번 들어갈 수 있으므로 최신이 아닌 것은 건너뛴다.9. 작업 스케줄링 패턴여러 작업이 주어지고, 시간 순서대로 처리하며 우선순위에 따라 선택하는 문제다.접근법:1. 작업을 시작 시간 기준으로 정렬2. 현재 시간까지 시작 가능한 작업을 힙에 넣음3. 힙에서 우선순위 높은 것을 꺼내서 처리이 패턴은 그리디 + 힙의 조합이다.10. PriorityQueue 주의사항1) remove(Object)는 O(N)이다pq.remove(5); // O(N) 선형 탐색삭제가 잦으면 TreeMap이나 lazy deletion을 쓴다.2) Iterator 순서는 정렬 순서가 아니다// 이렇게 하면 정렬된 순서로 출력되지 않는다for (int x : pq) { System.out.println(x);}// poll()을 반복해야 정렬 순서while (!pq.isEmpty()) { System.out.println(pq.poll());}3) null을 넣으면 안 된다PriorityQueue는 null을 허용하지 않는다. NullPointerException이 발생한다.11. 힙을 직접 구현할 때코테에서는 거의 PriorityQueue를 쓰지만,원리를 이해하기 위해 직접 구현해 보면 좋다.class MinHeap { int[] heap; int size; MinHeap(int capacity) { heap = new int[capacity]; size = 0; } void offer(int val) { heap[size] = val; siftUp(size); size++; } int poll() { int min = heap[0]; size--; heap[0] = heap[size]; siftDown(0); return min; } void siftUp(int i) { while (i > 0) { int parent = (i - 1) / 2; if (heap[parent] <= heap[i]) break; swap(parent, i); i = parent; } } void siftDown(int i) { while (2 * i + 1 < size) { int child = 2 * i + 1; if (child + 1 < size && heap[child + 1] < heap[child]) { child++; } if (heap[i] <= heap[child]) break; swap(i, child); i = child; } } void swap(int a, int b) { int tmp = heap[a]; heap[a] = heap[b]; heap[b] = tmp; }}12. 자주 하는 실수1) 최소 힙과 최대 힙을 혼동기본이 최소 힙이다.최댓값을 꺼내고 싶으면 Collections.reverseOrder()를 쓰거나음수로 넣어서 꺼낼 때 부호를 되돌린다.pq.offer(-value); // 음수로 넣기int max = -pq.poll(); // 꺼낼 때 부호 되돌리기2) Top-K에서 힙 방향을 잘못 잡음 가장 큰 K개 → 최소 힙 크기 K 가장 작은 K개 → 최대 힙 크기 K직관과 반대이므로 주의해야 한다.3) Lazy Deletion을 안 함다익스트라에서 PQ에 같은 노드가 여러 번 들어갈 수 있다.if (dist > d[node]) continue;를 빠뜨리면 TLE가 난다.13. 시험장용 최소 암기 버전최소 힙:PriorityQueue<Integer> pq = new PriorityQueue<>();최대 힙:PriorityQueue<Integer> pq = new PriorityQueue<>(Collections.reverseOrder());커스텀:new PriorityQueue<>((a, b) -> Integer.compare(a[0], b[0]));Top-K 가장 큰 것:최소 힙 크기 K 유지 → O(N log K)핵심 메서드:offer(), poll(), peek(), size(), isEmpty()14. 최종 요약힙은 다음 문장으로 정리할 수 있다.삽입과 최솟값 추출을 모두 O(log N)에 처리하는 자료구조문제를 보면 이 질문을 하면 된다."반복적으로 최솟값(또는 최댓값)을 꺼내야 하는가?"→ 그렇다면 힙이다정렬과의 차이를 기억하면 된다.정렬: 한 번에 전체를 순서대로 → O(N log N)힙: 하나씩 반복적으로 최솟값을 꺼냄 → 각각 O(log N)
- Hash Algorithm algorithm hash hashmap Hash Hash해시(Hash)는 키를 빠르게 저장하고 조회하기 위한 자료구조이자 기법이다.한 줄로 요약하면 다음과 같다.키 → 해시 함수 → 인덱스평균 O(1)에 저장, 조회, 삭제1. 언제 쓰는가문제에서 아래 표현이 보이면 해시를 먼저 떠올리면 된다. 특정 값이 존재하는지 빠르게 확인 등장 횟수 세기 중복 제거 두 수의 합 문자열/배열에서 특정 패턴 매칭 그룹핑대표 문제 유형: 전화번호부 (HashMap) 의상 조합 (경우의 수 + HashMap) 베스트앨범 (그룹핑 + 정렬) 합이 K인 부분 배열 개수2. 핵심 자료구조Java에서 해시 기반 자료구조는 크게 두 가지다.HashMap키-값 쌍을 저장한다.Map<String, Integer> map = new HashMap<>();map.put("apple", 3);map.get("apple"); // 3map.containsKey("apple"); // truemap.getOrDefault("banana", 0); // 0HashSet값의 존재 여부만 저장한다.Set<String> set = new HashSet<>();set.add("apple");set.contains("apple"); // trueset.remove("apple");flowchart LR K["키"] --> H["해시 함수"] H --> I["인덱스"] I --> B["버킷"] B --> V["값 저장/조회"]핵심은 해시 함수가 키를 인덱스로 바꿔서 배열처럼 바로 접근한다는 점이다. 이 덕분에 평균 O(1)에 동작한다.다만 충돌이 심하면 최악에는 더 느려질 수 있으므로, 코테에서는 보통 “평균 O(1)”이라고 이해하는 것이 정확하다.3. 빈도 세기 패턴가장 자주 나오는 해시맵 패턴이다.Map<String, Integer> freq = new HashMap<>();for (String s : arr) { freq.put(s, freq.getOrDefault(s, 0) + 1);}이 패턴은 다음 문제에서 매우 빈번하게 쓰인다. 최빈 원소 찾기 중복 확인 애너그램 비교 문자 종류별 개수4. 존재 확인 패턴배열이나 리스트에서 특정 값이 있는지 O(1)에 확인한다.Set<Integer> seen = new HashSet<>();for (int x : arr) { if (seen.contains(target - x)) { // target - x와 x의 합이 target } seen.add(x);}이 패턴은 “두 수의 합” 문제의 해시 풀이법이다.5. 두 수의 합: 해시 vs 투 포인터같은 문제를 두 가지로 풀 수 있다. 방법 전제 조건 시간 특징 해시맵 없음 O(N) 정렬 불필요 투 포인터 정렬 필요 O(N log N) 추가 공간 적음 정렬되지 않은 배열이면 해시가 더 자연스럽고,정렬된 배열이면 투 포인터가 더 자연스럽다.6. 그룹핑 패턴해시맵으로 값을 그룹별로 묶는 패턴이다.Map<String, List<Integer>> groups = new HashMap<>();for (int i = 0; i < n; i++) { List<Integer> list = groups.get(genre[i]); if (list == null) { list = new ArrayList<>(); groups.put(genre[i], list); } list.add(i);}이 패턴은 다음 문제에서 자주 나온다. 장르별 곡 분류 팀별 점수 집계 카테고리별 그룹핑7. 애너그램 판별두 문자열이 같은 문자 구성인지 확인하는 문제다.방법 1: 정렬 비교char[] a = s1.toCharArray();char[] b = s2.toCharArray();Arrays.sort(a);Arrays.sort(b);return Arrays.equals(a, b);방법 2: 빈도 배열int[] count = new int[26];for (char c : s1.toCharArray()) count[c - 'a']++;for (char c : s2.toCharArray()) count[c - 'a']--;for (int c : count) { if (c != 0) return false;}return true;문자 종류가 적으면 빈도 배열, 유니코드 등 범위가 넓으면 HashMap이 적절하다.8. 슬라이딩 윈도우 + 해시맵윈도우 안의 문자 빈도를 실시간으로 관리하는 패턴이다.문자열에서 길이 K인 연속 부분에 포함된 문자 종류 수의 최댓값Map<Character, Integer> window = new HashMap<>();// 초기 윈도우for (int i = 0; i < k; i++) { char ch = s.charAt(i); window.put(ch, window.getOrDefault(ch, 0) + 1);}int answer = window.size();for (int i = k; i < s.length(); i++) { // 오른쪽 추가 char in = s.charAt(i); window.put(in, window.getOrDefault(in, 0) + 1); // 왼쪽 제거 char out = s.charAt(i - k); window.put(out, window.get(out) - 1); if (window.get(out) == 0) window.remove(out); answer = Math.max(answer, window.size());}핵심은 빈도가 0이 되면 키를 삭제하는 것이다.그래야 window.size()가 정확한 종류 수를 반환한다.9. 누적합 + 해시맵이 조합은 “합이 K인 부분 배열 개수” 문제에서 핵심이다.Map<Long, Integer> freq = new HashMap<>();freq.put(0L, 1);long sum = 0;int count = 0;for (int x : arr) { sum += x; count += freq.getOrDefault(sum - k, 0); freq.put(sum, freq.getOrDefault(sum, 0) + 1);}원리:sum[0..i] - sum[0..j] = K→ sum[0..j] = sum[0..i] - K이전에 sum - K가 나온 횟수만큼 합이 K인 구간이 존재한다.flowchart TD A["현재 누적합 sum 계산"] --> B{"freq에<br>sum - K가 있는가?"} B -->|Yes| C["count += freq[sum - K]"] B -->|No| D["넘어감"] C --> E["freq[sum]++"] D --> E E --> F["다음 원소로"]이 패턴은 음수가 있는 배열에서도 동작하므로 슬라이딩 윈도우보다 범용적이다.10. 해시 충돌 개념해시 함수가 서로 다른 키를 같은 인덱스로 보내면 충돌이 발생한다.Java의 HashMap은 내부적으로 체이닝(Linked List / Red-Black Tree)으로 충돌을 처리한다.실전에서는: 충돌이 많아도 보통 문제 없다 극단적 입력에서는 O(N)까지 느려질 수 있다 코테에서는 거의 신경 쓸 필요 없다다만 직접 해시를 구현해야 하는 경우(문자열 해싱 등)는 충돌을 고려해야 한다.11. 문자열 해싱 (Rabin-Karp 방식)문자열을 정수로 변환하는 해시 기법이다.hash("abc") = 'a' * p^2 + 'b' * p^1 + 'c' * p^0여기서 p는 소수(보통 31), 모든 계산은 큰 소수 MOD로 나머지를 취한다.long hash(String s) { long h = 0; long p = 1; long MOD = 1_000_000_007; for (int i = s.length() - 1; i >= 0; i--) { h = (h + (s.charAt(i) - 'a' + 1) * p) % MOD; p = p * 31 % MOD; } return h;}문자열 해싱의 장점: 롤링 해시를 쓰면 부분 문자열의 해시값을 O(1)에 비교할 수 있다 슬라이딩 윈도우와 결합하기 좋다주의점: 해시 충돌이 발생할 수 있으므로, 충돌이 의심되면 이중 해시를 쓴다12. LinkedHashMap과 순서 보존일반 HashMap은 삽입 순서를 보장하지 않는다.삽입 순서가 중요하면 LinkedHashMap을 쓴다.Map<String, Integer> map = new LinkedHashMap<>();map.put("b", 2);map.put("a", 1);map.put("c", 3);// 순회하면 b, a, c 순서 보장for (Map.Entry<String, Integer> entry : map.entrySet()) { System.out.println(entry.getKey());}13. TreeMap과 정렬된 키키를 정렬 순서로 유지해야 하면 TreeMap을 쓴다.TreeMap<Integer, String> map = new TreeMap<>();map.put(3, "c");map.put(1, "a");map.put(2, "b");map.firstKey(); // 1map.lastKey(); // 3map.floorKey(2); // 2 이하 중 최대map.ceilingKey(2); // 2 이상 중 최소TreeMap은 O(log N)이므로 HashMap보다 느리지만,범위 검색이 필요한 문제에서 강력하다.14. 자주 하는 실수1) getOrDefault를 안 쓰고 NPE 발생// 위험int val = map.get(key); // key가 없으면 NPE// 안전int val = map.getOrDefault(key, 0);2) 빈도 0인 키를 삭제하지 않음슬라이딩 윈도우에서 size()를 쓸 때 특히 중요하다.3) 해시맵 키로 배열을 씀Java에서 int[]는 equals / hashCode가 참조 기반이라 HashMap 키로 쓰면 안 된다.Arrays.toString(arr)이나 List<Integer>로 변환해야 한다.4) 순서를 기대하면서 HashMap을 씀순서가 중요하면 LinkedHashMap이나 TreeMap을 써야 한다.5) 해시맵을 써야 하는데 정렬 후 이분탐색으로 풀어서 복잡도 증가바로 O(1) 조회가 필요한 경우 해시가 더 단순하다.15. 실전 판단 기준아래 상황이면 해시를 먼저 떠올리면 된다. 값이 존재하는지 빠르게 확인 등장 횟수를 세야 한다 두 값의 관계를 빠르게 확인 (합, 차, 짝) 윈도우 안의 구성 요소를 실시간 추적 누적합과 결합하여 조건 만족 구간 세기해시가 아닌 경우: 순서가 중요하다 → 정렬 / TreeMap 범위 쿼리가 중요하다 → TreeMap / 세그먼트 트리 데이터가 매우 적다 → 단순 배열로 충분16. 시험장용 최소 암기 버전해시:키 → 인덱스 → O(1) 접근주요 패턴:빈도 세기: getOrDefault + put존재 확인: HashSet.contains그룹핑: 없으면 생성 후 add누적합 결합: freq[sum - K]자료구조 선택:HashMap: 기본LinkedHashMap: 순서 보존TreeMap: 정렬 + 범위주의:배열을 키로 쓰지 말 것빈도 0이면 remove17. 최종 요약해시는 다음 문장으로 정리할 수 있다.키를 해시 함수로 인덱스로 바꿔삽입, 조회, 삭제를 평균 O(1)에 처리하는 자료구조핵심만 다시 압축하면: HashMap은 코테에서 가장 자주 쓰이는 자료구조 중 하나다 빈도 세기, 존재 확인, 그룹핑이 3대 패턴이다 누적합 + 해시맵은 “합이 K인 구간 수” 문제의 정석이다 슬라이딩 윈도우 + 해시맵은 윈도우 내 상태 관리의 핵심이다문제를 보면 먼저 이 질문을 하면 된다.특정 값의 존재나 빈도를 빠르게 알아야 하는가?답이 예라면 해시일 가능성이 높다.
- Greedy Algorithm algorithm greedy Greedy Greedy그리디(Greedy)는 매 순간 가장 좋아 보이는 선택을 하면서 전체 해를 만드는 알고리즘 기법이다.한 줄로 요약하면 다음과 같다.지금 당장 가장 이득인 선택을 반복한다하지만 중요한 점은 이것이다.그리디는 "규칙을 찾는 것"보다그 규칙이 왜 항상 맞는지 설명하는 것이 더 중요하다1. 언제 쓰는가문제에서 아래 느낌이 나면 그리디를 의심하면 된다. 최댓값 / 최솟값을 빠르게 만들어야 함 매 순간 하나를 골라 나가는 구조 정렬 후 순서대로 처리하는 것이 자연스러움 가장 빨리 끝나는 것, 가장 싼 것, 가장 큰 것부터 고르는 규칙이 보임 현재 선택이 이후 선택 가능성을 넓혀 줌대표 문제 유형: 회의실 배정 동전 거스름돈 일부 최소 신장 트리 일부 절차 문자열 / 숫자 만들기 최적화 스케줄링2. 핵심 아이디어그리디의 핵심은 다음 한 문장이다.지금 최선인 선택이 전체 최적해를 해치지 않는다즉 현재 단계에서 “제일 좋아 보이는 것”을 골라도,나중에 후회하지 않는 구조여야 한다.이 조건이 없으면 그리디는 틀린다.flowchart TD A["문제 입력"] --> B["정렬 또는 우선순위 기준 설정"] B --> C["현재 최선 선택"] C --> D{"제약 조건 충족?"} D -->|Yes| E["선택 확정 & 답 갱신"] D -->|No| F["건너뛰기"] E --> G{"남은 후보가 있는가?"} F --> G G -->|Yes| C G -->|No| H["최종 답 반환"]그리디의 전형적인 흐름은 이와 같다. 정렬이나 우선순위 큐로 순서를 정한 뒤, 매 단계에서 가장 좋아 보이는 선택을 취하고, 조건에 맞지 않으면 건너뛴다.3. DP와의 차이그리디와 DP는 자주 비교된다. 항목 Greedy DP 선택 방식 현재 최선 하나만 선택 여러 상태를 비교 필요한 것 선택 규칙의 정당성 상태 정의와 점화식 강점 빠르고 구현이 단순 정답 보장이 강함 위험 규칙이 틀리면 전체가 틀림 상태 수가 많으면 무거움 즉, 현재 선택이 미래에 거의 영향을 안 주거나 오히려 미래를 넓혀 주는 구조면 그리디가 가능하다 미래 영향이 복잡하면 DP 가능성이 높다flowchart TD Q["최적해를 구하는 문제"] --> A{"현재 선택이<br>미래에 영향을 주는가?"} A -->|"거의 없음"| B{"정렬 후 한 방향으로<br>처리 가능한가?"} B -->|Yes| C["Greedy"] B -->|No| D["다른 접근"] A -->|"복잡함"| E{"상태를 정의하고<br>점화식을 세울 수 있는가?"} E -->|Yes| F["DP"] E -->|No| G["완전탐색 / 그래프"]이 흐름을 머릿속에 두면 그리디와 DP를 구분하는 첫 판단이 빨라진다.4. 그리디가 성립하려면보통 아래 중 하나가 필요하다.1) 교환 논법 Exchange Argument최적해가 있다고 가정했을 때,현재 그리디 선택으로 바꿔도 손해가 없음을 보인다.2) 앞서감 유지 Staying Ahead그리디가 매 단계에서 항상 다른 해보다 뒤처지지 않음을 보인다.3) 정렬 후 규칙 고정정렬 기준 하나가 전체 선택 규칙을 결정한다.즉 그리디는 “감”이 아니라 증명이 필요한 알고리즘이다.실전에서는 아래 세 문장을 스스로 설명할 수 있어야 그리디라고 봐도 된다. 왜 지금 이 선택이 다음 선택지를 가장 덜 해치는가 한 번 선택하고 나면 남은 문제가 원래 문제와 같은 구조를 유지하는가 최적해를 그리디 선택으로 바꿔도 손해가 없다는 논리를 만들 수 있는가이 셋 중 하나라도 설명이 막히면, 그 문제는 그리디보다 DP나 완전탐색일 가능성을 먼저 의심하는 편이 안전하다.5. 대표 예시: 회의실 배정문제:겹치지 않게 가장 많은 회의를 선택하라회의가 (start, end) 형태로 주어진다고 하자.왜 “가장 빨리 끝나는 회의”를 고를까현재 시점에서 가장 빨리 끝나는 회의를 고르면,뒤에 더 많은 회의를 넣을 공간이 남는다.반대로 늦게 끝나는 회의를 먼저 고르면,뒤의 선택지가 줄어든다.즉 이 문제의 핵심 규칙은:끝나는 시간이 빠른 순으로 보자이다.6. 손으로 따라가는 예시회의 목록:(1, 4)(2, 3)(3, 5)(4, 6)끝나는 시간 기준으로 정렬하면:(2, 3)(1, 4)(3, 5)(4, 6)이제 순서대로 보자. (2, 3) 선택 (1, 4)는 시작 시간이 3보다 작아서 불가능 (3, 5)는 가능 -> 선택 (4, 6)는 시작 시간이 5보다 작아 불가능정답: 2개이 예시에서 (1, 4)를 먼저 골랐다면 뒤 선택지가 오히려 줄어든다.타임라인 시각화time: 1 2 3 4 5 6 [ 2,3 ] ← 선택 ✓[ 1,4 ] ← 겹침 ✗ [ 3,5 ] ← 선택 ✓ [ 4,6 ] ← 겹침 ✗끝나는 시간이 빠른 (2,3)을 먼저 선택하면 (3,5)까지 총 2개를 배정할 수 있다.7. 회의실 배정import java.util.*;class Meeting { int start; int end; Meeting(int start, int end) { this.start = start; this.end = end; }}int maxMeetings(List<Meeting> meetings) { meetings.sort((a, b) -> { if (a.end != b.end) return a.end - b.end; return a.start - b.start; }); int count = 0; int lastEnd = -1; for (Meeting m : meetings) { if (m.start >= lastEnd) { count++; lastEnd = m.end; } } return count;}타이브레이크로 시작 시간도 정렬해 두면 구현이 안정적이다.8. 왜 이 규칙이 맞는가: 짧은 증명 감각최적해 중 첫 번째 회의가 가장 빨리 끝나는 회의가 아니라고 하자.그 첫 회의를 더 빨리 끝나는 회의로 바꿔도,뒤에 들어갈 수 있는 회의 수는 줄지 않는다.왜냐하면 더 빨리 끝나므로 뒤 공간이 더 넓거나 같기 때문이다.즉 최적해를 그리디 선택으로 바꿔도 손해가 없다.이게 교환 논법의 전형적인 형태다.9. 정렬 기반 그리디그리디는 정렬과 같이 나오는 경우가 매우 많다.예: 끝나는 시간이 빠른 순 마감이 빠른 순 가치가 큰 순 무게가 작은 순 위치가 가까운 순즉 문제를 보면 먼저 다음을 묻게 된다.무엇을 기준으로 정렬하면 안전한 선택 순서가 생기는가?10. 우선순위 큐 기반 그리디정렬만으로는 안 되고,현재 선택 가능한 것들 중에서 최선 하나를 계속 골라야 하는 문제도 있다.이 경우는 우선순위 큐가 붙는다.대표 예시: 최소 강의실 수 마감이 있는 작업 선택 가장 큰 값 / 가장 작은 값 반복 선택즉 그리디는 보통 다음 두 도구와 함께 나온다. 정렬 우선순위 큐11. 동전 문제는 왜 때로는 되고 때로는 안 되는가예: 500, 100, 50, 10원 체계 -> 큰 동전부터 써도 최적 1, 3, 4원 체계에서 6 만들기 -> 큰 동전부터 쓰면 4 + 1 + 1 = 3개, 실제 최적은 3 + 3 = 2개즉:그리디 규칙이 자연스러워 보여도 항상 맞는 것은 아니다동전 문제는 그리디 반례를 배우기 가장 좋은 예다.반례를 먼저 만드는 습관이 중요하다그리디 규칙 후보가 떠오르면,증명부터 하기 전에 작은 반례를 먼저 만들어 보는 편이 빠르다.예를 들어 “항상 가장 큰 동전부터 쓰자”는 규칙은1, 3, 4 체계에서 목표 6만 넣어 봐도 바로 깨진다.실전에서는 다음 순서가 안전하다. 규칙 후보를 한 문장으로 적는다. 작은 입력에서 반례를 만들어 본다. 반례가 안 나오면 교환 논법이나 앞서감 유지로 설명을 붙인다.그리디는 구현보다“왜 지금 선택이 틀리지 않는가”를 검증하는 과정이 핵심이다.12. 그리디를 의심하는 신호아래 신호가 보이면 그리디 가능성이 높다. 매 단계에서 하나씩 고르는 구조 정렬 후 한 번만 훑으면 될 것 같음 현재 선택이 뒤 선택의 공간을 넓힘 지역 최적이 전역 최적으로 이어질 것 같은 느낌 증명은 짧은데 구현은 간단함13. 그리디가 아닌 경우의 대표 신호아래라면 그리디를 조심해야 한다. 현재 선택이 미래에 큰 영향을 줌 반례가 쉽게 떠오름 “한 번 잘못 고르면 뒤에서 복구 불가” 구조 최댓값 / 최솟값이 상태에 많이 의존함즉 미래 영향이 복잡하면 DP나 다른 접근이 필요할 가능성이 높다.14. 자주 하는 실수1) 증명 없이 감으로 규칙을 정함그리디는 반례가 생기기 쉬워서 가장 위험한 실수다.2) 정렬 기준을 잘못 잡음회의실 배정도 시작 시간 기준으로 정렬하면 틀린다.핵심은 끝나는 시간이다.3) 현재 최선과 전체 최선을 혼동함지금 좋아 보여도 미래 공간을 좁히면 틀릴 수 있다.4) 사실은 DP 문제인데 그리디로 억지 처리함반례를 하나만 만들어 봐도 드러나는 경우가 많다.15. 시험장용 최소 암기 버전그리디:지금 최선 선택 반복핵심 질문:왜 지금 선택이 항상 안전한가?자주 같이 나오는 것:정렬우선순위 큐대표 예시:회의실 배정16. 최종 요약그리디는 다음 문장으로 정리할 수 있다.매 순간 최선의 선택을 하되,그 선택이 전체 최적해를 해치지 않는 문제에서 쓰는 기법문제를 보면 먼저 이 질문을 하면 된다.현재 가장 좋아 보이는 선택을 지금 해도,나중에 후회하지 않는다고 설명할 수 있는가?설명할 수 있으면 그리디일 가능성이 높다.
- Graph Algorithm algorithm graph Graph Graph그래프(Graph)는 정점(Vertex)과 간선(Edge)으로 이루어진 자료구조다.한 줄로 요약하면 다음과 같다.정점 사이의 연결 관계를 표현하는 가장 일반적인 구조트리도 그래프의 한 종류이고,네트워크, 길 찾기, 관계 비교, 순서 제약, 연결 요소, 최단 거리 문제는 대부분 그래프로 모델링할 수 있다.1. 언제 그래프 문제인가문제에서 아래 표현이 보이면 그래프를 먼저 떠올리면 된다. 도시와 도로 사람과 친구 관계 컴퓨터 네트워크 연결 노드와 간선 이동 가능 여부 최단 거리 선후 관계 연결 요소 개수즉 대상이 무엇이든,무언가와 무언가 사이의 관계나 연결을 다루면 그래프일 가능성이 높다.2. 핵심 용어그래프를 이해할 때 꼭 알아야 하는 용어는 다음과 같다. 정점 Vertex: 점, 노드 간선 Edge: 정점과 정점을 잇는 선 인접 Adjacent: 간선으로 직접 연결된 상태 경로 Path: 여러 간선을 따라 이동한 순서 사이클 Cycle: 다시 자기 자신으로 돌아오는 경로 차수 Degree: 정점에 연결된 간선 수 연결 요소 Connected Component: 서로 도달 가능한 정점들의 묶음예를 들어:1 -- 2 -- 3| |4 ------- 5이 구조 전체가 그래프다.3. 그래프의 종류그래프는 여러 기준으로 나뉜다.1) 무방향 그래프 Undirected Graph간선에 방향이 없다.1 -- 2는 1에서 2로도 갈 수 있고, 2에서 1로도 갈 수 있다.2) 방향 그래프 Directed Graph간선에 방향이 있다.1 -> 2는 1에서 2로는 갈 수 있지만, 2에서 1로는 못 갈 수 있다.3) 가중치 그래프 Weighted Graph간선마다 비용이 있다.1 --(5)--> 24) 비가중치 그래프 Unweighted Graph간선 비용이 없거나 모두 동일하다.5) 트리 Tree사이클이 없는 연결 그래프다.즉 트리는 그래프의 특수한 형태다.4. 그래프를 어떻게 모델링할까그래프 문제의 첫 단계는 그래프를 어떤 자료구조로 표현할지 결정하는 것이다.대표적으로 두 가지가 있다.1) 인접 행렬 Adjacency Matrixint[][] graph = new int[n][n];의미:graph[i][j] == 1 // i와 j가 연결됨장점: 연결 여부 확인이 O(1) 구현이 직관적단점: 메모리가 O(N^2) 실제 간선이 적어도 모든 칸을 써야 함즉 정점 수가 작을 때만 적합하다.2) 인접 리스트 Adjacency ListArrayList<Integer>[] graph = new ArrayList[n + 1];장점: 메모리가 O(N + E) 현재 정점과 연결된 간선만 볼 수 있어서 효율적단점: 두 정점이 직접 연결됐는지 즉시 확인하려면 느릴 수 있음코테에서는 대부분 인접 리스트가 정석이다.5. 인접 행렬 vs 인접 리스트 표현 방식 장점 단점 주 사용처 인접 행렬 연결 여부 O(1) 메모리 많이 사용 정점 수가 작고 관계가 조밀할 때 인접 리스트 메모리 효율적, 탐색 효율적 직접 연결 여부 확인은 느릴 수 있음 대부분의 그래프 문제 실전 판단 기준: N이 작다 -> 인접 행렬도 가능 N이 크고 간선만 주어진다 -> 인접 리스트 DFS/BFS/Dijkstra -> 인접 리스트 우선6. 가중치 그래프의 인접 리스트가중치가 있는 그래프는 목적지와 비용을 함께 저장해야 한다.static class Edge { int to; int weight; Edge(int to, int weight) { this.to = to; this.weight = weight; }}ArrayList<Edge>[] graph = new ArrayList[n + 1];즉 비가중치 그래프는 Integer만 저장하면 되고,가중치 그래프는 Edge 같은 클래스를 따로 둔다.7. 그래프 문제 분류를 위한 빠른 기준그래프 문제를 만나면 먼저 아래처럼 분류하면 된다.flowchart TD A["그래프 문제"] --> B{"가중치 있음"} B -->|No| C{"최단 경로 필요"} C -->|Yes| D["BFS"] C -->|No| E["DFS / BFS / Union-Find"] B -->|Yes| F{"음수 간선 존재"} F -->|No| G["Dijkstra"] F -->|Yes| H["Bellman-Ford / Floyd-Warshall"] G --> I{"모든 쌍 최단 경로"} I -->|Yes| J["Floyd-Warshall"] I -->|No| K["Dijkstra"]이 다이어그램은 문제를 처음 읽을 때 빠르게 방향을 잡는 기준으로 쓰면 된다.8. BFS 너비 우선 탐색BFS(Breadth-First Search)는 가까운 정점부터 순서대로 탐색하는 알고리즘이다.핵심 자료구조는 Queue다.언제 쓰는가 가중치 없는 그래프의 최단 거리 연결 요소 탐색 레벨 순회 최소 이동 횟수기본 흐름 시작점을 큐에 넣는다 큐에서 하나 꺼낸다 인접 정점 중 방문하지 않은 정점을 큐에 넣는다 큐가 빌 때까지 반복한다void bfs(int start, ArrayList<Integer>[] graph, boolean[] visited) { Queue<Integer> q = new LinkedList<>(); q.offer(start); visited[start] = true; while (!q.isEmpty()) { int cur = q.poll(); for (int next : graph[cur]) { if (visited[next]) continue; visited[next] = true; q.offer(next); } }}9. BFS는 왜 최단 거리인가비가중치 그래프에서 BFS가 최단 거리를 구할 수 있는 이유는,큐가 거리 순서대로 정점을 확장하기 때문이다.즉: 시작점에서 1번 만에 가는 정점들 2번 만에 가는 정점들 3번 만에 가는 정점들순서로 탐색이 진행된다.그래서 처음 도달한 거리가 곧 최단 거리다.거리 배열 기반 BFSint[] dist = new int[n + 1];Arrays.fill(dist, -1);Queue<Integer> q = new LinkedList<>();q.offer(start);dist[start] = 0;while (!q.isEmpty()) { int cur = q.poll(); for (int next : graph[cur]) { if (dist[next] != -1) continue; dist[next] = dist[cur] + 1; q.offer(next); }}10. DFS 깊이 우선 탐색DFS(Depth-First Search)는 한 방향으로 끝까지 내려갔다가 돌아오는 탐색이다.핵심은 재귀 또는 스택이다.언제 쓰는가 연결 요소 탐색 경로 존재 여부 사이클 탐지 트리 순회 백트래킹 기반 탐색재귀 방식void dfs(int cur, ArrayList<Integer>[] graph, boolean[] visited) { visited[cur] = true; for (int next : graph[cur]) { if (visited[next]) continue; dfs(next, graph, visited); }}DFS는 구조를 깊게 타고 들어가므로,서브트리 계산이나 재귀적 구조의 문제에서 특히 강하다.11. DFS와 BFS의 차이 항목 BFS DFS 자료구조 Queue Recursion / Stack 탐색 방식 가까운 곳부터 한 방향 끝까지 강점 비가중치 최단 거리 구조 파악, 백트래킹, 서브트리 계산 즉: 최단 거리 느낌 -> BFS 구조 분해 느낌 -> DFS로 먼저 떠올리면 된다.12. 연결 요소 Connected Components그래프가 여러 덩어리로 나뉘어 있을 수 있다.예:1 -- 2 3 -- 4 5이 경우 연결 요소는 3개다.연결 요소 개수를 세는 기본 패턴은 다음과 같다.int countComponents(int n, ArrayList<Integer>[] graph) { boolean[] visited = new boolean[n + 1]; int count = 0; for (int i = 1; i <= n; i++) { if (visited[i]) continue; dfs(i, graph, visited); count++; } return count;}즉 아직 방문하지 않은 정점에서 탐색을 한 번 시작할 때마다 새로운 연결 요소 하나를 찾은 것이다.13. 사이클 탐지사이클은 그래프 문제의 핵심 개념 중 하나다.무방향 그래프DFS 중에 방문한 정점을 다시 만났는데 그 정점이 부모가 아니면 사이클이다.boolean hasCycle = false;void dfs(int cur, int parent, boolean[] visited, ArrayList<Integer>[] graph) { visited[cur] = true; for (int next : graph[cur]) { if (next == parent) continue; if (visited[next]) { hasCycle = true; return; } dfs(next, cur, visited, graph); }}방향 그래프현재 재귀 스택에 있는 정점을 다시 만나면 사이클이다.이를 구분하려면 visited만으로는 부족하고, 3색 방문 배열이 필요하다.// 0: 미방문, 1: 재귀 스택(현재 탐색 중), 2: 완료int[] state;boolean hasCycle = false;void dfs(int cur, ArrayList<Integer>[] graph) { state[cur] = 1; for (int next : graph[cur]) { if (state[next] == 1) { hasCycle = true; return; } if (state[next] == 0) { dfs(next, graph); } } state[cur] = 2;}즉 사이클 탐지는 그래프 종류에 따라 방식이 다르다.flowchart TD A["사이클 탐지"] --> B{"무향 그래프?"} B -->|Yes| C["DFS: 방문한 이웃이 부모가 아님"] B -->|No| D["DFS: 이웃이 현재 재귀 스택에 있음"] C --> E["Union-Find 사용 가능"]무향 그래프는 “방문한 이웃이 부모만 아니면 사이클”로 볼 수 있지만,방향 그래프는 현재 DFS 경로 안으로 되돌아오는 간선인지까지 구분해야 한다.14. 위상 정렬 Topological Sort위상 정렬은 방향 그래프, 그중에서도 DAG(사이클 없는 방향 그래프)에서만 가능하다.의미:선후 관계를 만족하는 순서로 정점을 나열하기예: 선수 과목 작업 순서 빌드 순서Kahn 알고리즘 핵심 진입 차수 indegree가 0인 정점을 큐에 넣는다 하나 꺼내며 정답에 넣는다 그 정점에서 나가는 간선을 제거한 효과로 indegree를 줄인다 새로 indegree가 0이 된 정점을 큐에 넣는다List<Integer> topoSort(int n, ArrayList<Integer>[] graph, int[] indegree) { Queue<Integer> q = new LinkedList<>(); List<Integer> order = new ArrayList<>(); for (int i = 1; i <= n; i++) { if (indegree[i] == 0) q.offer(i); } while (!q.isEmpty()) { int cur = q.poll(); order.add(cur); for (int next : graph[cur]) { indegree[next]--; if (indegree[next] == 0) q.offer(next); } } return order;}정점 수만큼 결과가 나오지 않으면 사이클이 있다는 뜻이다.15. 최단 경로 문제 분류그래프 문제에서 가장 중요한 분기 중 하나다. 상황 알고리즘 가중치 없음 BFS 가중치 있음, 음수 없음, 시작점 하나 Dijkstra 음수 간선 가능 Bellman-Ford 모든 정점 쌍 최단 거리 Floyd-Warshall 즉 최단 경로 문제를 보면 먼저 다음을 확인한다. 가중치가 있는가? 음수 간선이 있는가? 시작점이 하나인가, 모든 정점인가?16. Dijkstra는 어떤 그래프에서 쓰는가Dijkstra는: 가중치가 있고 음수 간선이 없고 한 시작점에서 다른 정점까지의 최단 거리를 구할 때 사용한다.핵심 자료구조는 우선순위 큐다.가중치 그래프는 보통 인접 리스트로 표현한다.ArrayList<Edge>[] graph = new ArrayList[n + 1];자세한 내용은 Dijkstra.md에서 따로 정리하는 것이 맞고,Graph.md에서는 분류 기준만 잡는 것이 핵심이다.17. Floyd-Warshall은 어떤 그래프에서 쓰는가Floyd-Warshall은: 모든 정점 쌍의 최단 거리 정점 수가 크지 않음 경유지를 하나씩 허용하며 갱신이라는 특징이 있다.즉 시작점이 하나가 아니라 모든 쌍일 때 떠올리면 된다.이것도 자세한 내용은 FloydWarshall.md가 별도 정리 문서다.18. Bellman-Ford는 언제 필요한가Bellman-Ford는 음수 간선이 있을 수 있을 때 쓴다.속도는 느리지만,다익스트라와 달리 음수 간선과 음수 사이클 판별을 다룰 수 있다.즉: 음수 간선이 없다 -> Dijkstra 우선 음수 간선이 있다 -> Bellman-Ford 검토19. Union-Find는 그래프 탐색과 다르다Union-Find는 그래프를 탐색하는 알고리즘이 아니다.하지만 그래프 문제에서 매우 자주 쓰인다.주 사용처: 같은 연결 요소인지 판별 간선 추가 시 사이클 검사 Kruskal MST즉 탐색이 아니라 집합 병합 문제일 때 쓰는 도구다.20. 최소 스패닝 트리 MSTMST(Minimum Spanning Tree)는 가중치 무방향 그래프에서,모든 정점을 연결하되 간선 가중치 합이 최소가 되도록 하는 트리다.대표 알고리즘: Kruskal PrimKruskal 간선을 가중치 순으로 정렬 사이클이 생기지 않으면 채택 Union-Find와 궁합이 매우 좋다Prim 현재 연결된 정점 집합에서 가장 싼 간선을 하나씩 확장 우선순위 큐를 사용MST 문제는 최단 경로 문제와 헷갈리기 쉽지만 다르다. 최단 경로: 한 점에서 다른 점까지 가장 짧은 경로 MST: 전체 그래프를 가장 싸게 연결21. 그래프 입력에서 자주 하는 실수1) 무방향 그래프인데 한 방향만 넣음graph[u].add(v);graph[v].add(u);둘 다 넣어야 한다.2) 1-based, 0-based 인덱스를 섞음문제 입력이 보통 1번 정점부터 시작하므로 주의해야 한다.3) 방문 배열 초기화를 안 함BFS/DFS를 여러 번 돌릴 때 특히 자주 틀린다.4) 가중치 그래프인데 int overflow를 무시함최단 거리 문제에서는 long이 더 안전할 수 있다.5) 인접 행렬과 인접 리스트를 문제 크기에 맞지 않게 선택함N = 100000인데 인접 행렬은 거의 불가능하다.22. 그래프 문제를 읽을 때의 체크리스트문제를 받으면 아래 순서대로 보면 된다. 정점과 간선이 무엇인가? 방향 그래프인가, 무방향 그래프인가? 가중치가 있는가? 연결 여부가 중요한가, 최단 거리가 중요한가? 시작점이 하나인가, 여러 개인가? 선후 관계인가? 그래프가 사실 트리인가?이렇게 분류하면 대부분 알고리즘이 빠르게 정해진다.23. 문제 유형별 빠른 연결 문제 표현 보통 연결되는 알고리즘 연결 요소 개수 DFS / BFS / Union-Find 가중치 없는 최단 거리 BFS 가중치 있는 최단 거리 Dijkstra / Bellman-Ford / Floyd-Warshall 선수 관계, 작업 순서 Topological Sort 모든 노드 연결 최소 비용 MST 트리 구조 계산 DFS / Tree DP / LCA 이 표를 머릿속에 두면 그래프 문제를 훨씬 빨리 읽을 수 있다.24. 시험장용 최소 암기 버전그래프:정점 + 간선표현:인접 행렬 / 인접 리스트탐색:BFS = 가중치 없는 최단 거리DFS = 구조 파악, 서브트리, 백트래킹최단 경로:무가중치 -> BFS가중치, 음수 없음 -> Dijkstra음수 가능 -> Bellman-Ford모든 쌍 -> Floyd-Warshall기타:DAG -> 위상 정렬사이클 검사 / MST -> Union-Find, Kruskal25. 최종 요약그래프는 다음 문장으로 정리할 수 있다.정점 사이의 연결 관계를 표현하는 가장 일반적인 자료 구조핵심만 다시 압축하면: 그래프 문제는 관계와 연결을 모델링하는 문제다 대부분 인접 리스트가 기본 표현이다 가중치 없는 탐색은 BFS/DFS 최단 경로는 BFS, Dijkstra, Bellman-Ford, Floyd-Warshall로 분기한다 선후 관계는 위상 정렬 집합 병합은 Union-Find 전체 연결 최소 비용은 MST문제를 보면 먼저 이 질문을 하면 된다.이 그래프 문제에서 내가 필요한 것은탐색인가,최단 거리인가,순서인가,집합 병합인가?이 구분이 잡히면 그래프 문제는 절반 이상 풀린다.
- Floyd-Warshall Algorithm algorithm floyd-warshall Floyd-Warshall Floyd-WarshallFloyd-Warshall 알고리즘은 그래프에서 모든 정점 쌍 사이의 최단 거리를 구하는 대표적인 알고리즘이다.한 줄로 요약하면 다음과 같다.모든 정점을 경유지로 하나씩 허용하면서모든 정점 쌍의 최단 거리를 DP 방식으로 갱신하는 알고리즘1. 언제 쓰는가문제에서 아래 표현이 보이면 Floyd-Warshall 가능성을 먼저 생각하면 된다. 모든 정점 사이의 거리 모든 도시 쌍의 최소 비용 모든 학생 쌍의 키 비교 가능 여부 A에서 B로 갈 수 있는가를 모든 쌍에 대해 판단 정점 수는 작고, 모든 쌍 정보가 필요함대표적인 비교는 아래와 같다. 알고리즘 해결 대상 BFS 가중치 없는 그래프에서 한 시작점 기준 최단 거리 Dijkstra 한 시작점에서 모든 정점까지 최단 거리 Bellman-Ford 음수 간선이 있을 수 있는 한 시작점 최단 거리 Floyd-Warshall 모든 정점 쌍 사이 최단 거리 즉, Dijkstra는 Single Source Shortest Path Floyd-Warshall은 All Pairs Shortest Path문제를 읽었을 때 “시작점이 하나가 아니라 모든 정점이다”라는 느낌이 들면 거의 Floyd-Warshall 쪽이다.2. 핵심 아이디어두 정점 i, j 사이의 최단 거리를 구한다고 하자.처음에는 보통 이렇게 생각한다. i -> j로 직접 가는 비용하지만 실제 최단 경로는 중간 정점을 거칠 수 있다. i -> k -> j그리고 어떤 시점에는 단 하나의 정점만 거치는 것이 아니라 여러 정점을 거칠 수도 있다.그래서 Floyd-Warshall은 생각을 이렇게 바꾼다."중간 정점으로 1번까지만 허용했을 때의 최단 거리""중간 정점으로 1, 2번까지만 허용했을 때의 최단 거리""중간 정점으로 1, 2, 3번까지만 허용했을 때의 최단 거리"..."중간 정점으로 1..N을 모두 허용했을 때의 최단 거리"즉, 경유 가능한 정점의 집합을 조금씩 늘려 가면서 답을 만든다.이 점이 Floyd-Warshall의 본질이다.flowchart TD A["경유지 k 개방"] --> B["모든 쌍 (i,j) 검사"] B --> C{"k를 경유하는 경로가 더 짧음"} C -->|Yes| D["거리 갱신"] C -->|No| E["현재 거리 유지"]이 다이어그램처럼 Floyd-Warshall은 “경유지 k를 하나 열고, 모든 (i, j) 쌍을 검사해서 더 짧아지면 갱신”하는 과정을 반복한다.3. DP 관점에서의 정의개념적으로는 다음 상태를 생각하면 된다.dp[k][i][j] = 중간 정점으로 1..k까지만 사용할 수 있을 때 i에서 j까지 가는 최단 거리그러면 k번째 단계에서 가능한 선택은 딱 두 가지다. k를 경유하지 않는다. k를 경유한다.그래서 점화식은 다음과 같다.dp[k][i][j] = min( dp[k-1][i][j], dp[k-1][i][k] + dp[k-1][k][j] )이 식이 의미하는 바는 매우 직관적이다. 기존에 알고 있던 i -> j 최단 거리 k를 새로 경유지로 허용했을 때의 i -> k -> j 거리둘 중 더 짧은 것을 고르는 것이다.핵심 판단 구조graph LR i((i)) -->|"직접: dist[i][j]"| j((j)) i -->|"dist[i][k]"| k((k)) k -->|"dist[k][j]"| j각 (i, j) 쌍에 대해 직접 가는 것 vs k를 경유하는 것 중 더 짧은 경로를 선택한다.실제 구현에서는 dp[k] 전체를 따로 들고 있을 필요가 없다.현재 단계는 이전 단계 정보만 필요하므로 dist[i][j] 하나의 2차원 배열로 덮어쓰며 처리할 수 있다.4. 핵심 점화식실전에서 외워야 하는 식은 이것 하나다.dist[i][j] = Math.min(dist[i][j], dist[i][k] + dist[k][j]);의미: 현재 알고 있는 i -> j 최단 거리보다 i -> k -> j가 더 짧으면 갱신이 연산을 모든 k, 모든 i, 모든 j에 대해 반복하면 된다.5. 왜 k가 가장 바깥 루프인가Floyd-Warshall에서 루프 순서는 매우 중요하다.기본 구현은 보통 아래처럼 간다.for (int k = 1; k <= n; k++) { for (int i = 1; i <= n; i++) { for (int j = 1; j <= n; j++) { ... } }}이유는 k번째 반복이 가지는 의미 때문이다.k = 1 -> 중간 정점으로 1만 허용k = 2 -> 중간 정점으로 1, 2만 허용k = 3 -> 중간 정점으로 1, 2, 3만 허용...즉 k는 “이번 단계에서 새로 허용하는 경유지” 역할을 한다.만약 루프 순서를 바꾸면, 어떤 값은 아직 k가 허용되지 않은 상태여야 하는데 이미 다른 갱신이 섞여 들어가고 “1..k까지만 경유 가능”이라는 단계적 의미가 깨진다.핵심은 k가 가장 바깥 루프에 있어야 한다는 점이다.i와 j의 순서는 바뀌어도 되지만, k를 안쪽으로 넣으면 단계적 의미가 깨진다.6. 초기화Floyd-Warshall은 보통 인접 행렬 형태의 dist 배열을 사용한다.dist[i][j] = 현재까지 알고 있는 i에서 j까지의 최단 거리초기값은 다음처럼 잡는다. 경우 초기값 i == j 0 간선이 존재 그 간선의 가중치 간선이 없음 INF 예를 들면: 1 2 31 [ 0 4 INF ]2 [ INF 0 2 ]3 [ 1 INF 0 ]long INF = 1_000_000_000_000L;long[][] dist = new long[n + 1][n + 1];for (int i = 1; i <= n; i++) { Arrays.fill(dist[i], INF); dist[i][i] = 0;}간선 입력:dist[a][b] = Math.min(dist[a][b], cost);여기서 Math.min을 쓰는 이유는 같은 두 정점 사이에 여러 간선이 들어올 수 있기 때문이다.예: 1 -> 2 비용 10 1 -> 2 비용 3이 경우 초기값부터 3으로 잡아야 한다.무방향 그래프라면 양쪽 모두 갱신한다.dist[a][b] = Math.min(dist[a][b], cost);dist[b][a] = Math.min(dist[b][a], cost);7. INF를 잡을 때 주의할 점Floyd-Warshall에서 가장 흔한 실수 중 하나가 INF 처리다.예를 들어 dist[i][k]나 dist[k][j]가 도달 불가능이라면 둘을 더하면 안 된다.왜냐하면: 의미적으로는 “갈 수 없는 경로 + 어떤 비용”이기 때문이고 구현적으로는 overflow 위험이 있기 때문이다.그래서 아래 가드를 두는 것이 안전하다.if (dist[i][k] == INF || dist[k][j] == INF) continue;dist[i][j] = Math.min(dist[i][j], dist[i][k] + dist[k][j]);특히 int를 쓰면서 INF = Integer.MAX_VALUE처럼 잡아 두고 더해 버리면 overflow가 매우 쉽게 난다.실전 팁: 거리 합이 커질 수 있으면 long 사용 INF는 충분히 크되, 더해도 overflow가 나지 않을 정도로 설정예:long INF = 1_000_000_000_000L;8. 알고리즘 동작을 작은 예시로 이해하기다음 방향 그래프를 보자.1 -> 2 (4)1 -> 3 (15)1 -> 4 (10)2 -> 3 (3)3 -> 4 (2)초기 dist는 다음과 같다. 1 2 3 41 [ 0 4 15 10 ]2 [ INF 0 3 INF ]3 [ INF INF 0 2 ]4 [ INF INF INF 0 ]k = 1중간 정점으로 1만 허용한다. 다른 정점에서 1로 들어오는 길이 없으므로 거의 변화가 없다.k = 2이제 정점 2를 경유지로 쓸 수 있다.1 -> 3을 보자. 기존: 15 1 -> 2 -> 3: 4 + 3 = 7따라서 갱신:dist[1][3] = 7행렬은 이렇게 바뀐다. 1 2 3 41 [ 0 4 7 10 ]2 [ INF 0 3 INF ]3 [ INF INF 0 2 ]4 [ INF INF INF 0 ]k = 3이제 정점 3을 경유지로 허용한다.1 -> 4를 보자. 기존: 10 1 -> 3 -> 4: 7 + 2 = 9갱신:dist[1][4] = 9또 2 -> 4도 갱신된다. 기존: INF 2 -> 3 -> 4: 3 + 2 = 5갱신 후: 1 2 3 41 [ 0 4 7 9 ]2 [ INF 0 3 5 ]3 [ INF INF 0 2 ]4 [ INF INF INF 0 ]이 과정을 통해 “직접 연결보다 우회가 더 짧을 수 있다”는 점이 자연스럽게 반영된다.9. 기본 구현최단 거리만 필요할 때의 가장 기본적인 구현이다.import java.util.*;public class Main { static final long INF = 1_000_000_000_000L; public static void main(String[] args) { int n = 5; // 정점 개수 int m = 7; // 간선 개수 long[][] dist = new long[n + 1][n + 1]; // 초기화 for (int i = 1; i <= n; i++) { Arrays.fill(dist[i], INF); dist[i][i] = 0; } // 예시 간선 입력 int[][] edges = { {1, 2, 2}, {1, 3, 6}, {2, 3, 3}, {2, 4, 1}, {3, 5, 1}, {4, 5, 2}, {1, 5, 20} }; for (int[] e : edges) { int a = e[0]; int b = e[1]; int cost = e[2]; dist[a][b] = Math.min(dist[a][b], cost); } // Floyd-Warshall for (int k = 1; k <= n; k++) { for (int i = 1; i <= n; i++) { if (dist[i][k] == INF) continue; for (int j = 1; j <= n; j++) { if (dist[k][j] == INF) continue; dist[i][j] = Math.min(dist[i][j], dist[i][k] + dist[k][j]); } } } // 결과 출력 예시 for (int i = 1; i <= n; i++) { for (int j = 1; j <= n; j++) { if (dist[i][j] == INF) { System.out.print("INF "); } else { System.out.print(dist[i][j] + " "); } } System.out.println(); } }}10. 시간 복잡도와 메모리 복잡도Floyd-Warshall의 복잡도는 다음과 같다. 시간 복잡도: O(N^3) 메모리 복잡도: O(N^2)이 의미를 감각적으로 보면: 정점이 100개면 매우 가볍다. 정점이 200개면 실전에서 자주 나온다. 정점이 400개면 언어와 상수에 따라 가능하다. 정점이 1000개면 보통 너무 무겁다.대략적인 판단 기준: 정점 수 체감 N <= 100 매우 안전 N <= 200 자주 사용 가능 N <= 400 문제와 언어에 따라 가능 N >= 1000 보통 비현실적 메모리는 N x N 배열이므로 생각보다 빨리 커진다.예를 들어: N = 500 500 x 500 = 250000long[][] 하나만 놓고 보면 크게 무리는 없지만,추가 배열(next, reachable)까지 쓰면 메모리를 더 고려해야 한다.11. Dijkstra와 비교모든 쌍 최단 거리를 구하는 방법은 꼭 Floyd-Warshall만 있는 것은 아니다.방법 1. Dijkstra를 N번 수행각 정점을 시작점으로 하여 Dijkstra를 한 번씩 돌린다.장점: 희소 그래프에서 유리할 수 있다. E log V 기반이라 간선이 매우 적으면 효율적이다.단점: 구현이 더 길어질 수 있다. 음수 간선을 다룰 수 없다.방법 2. Floyd-Warshall 한 번 수행장점: 구현이 단순하다. 모든 쌍 결과가 한 번에 나온다. 음수 간선도 처리 가능하다.단점: O(N^3)이라 정점 수가 커지면 바로 부담된다.비교 표: 상황 추천 시작점이 하나 Dijkstra 모든 쌍 필요 Floyd-Warshall 음수 간선 존재 Floyd-Warshall 또는 Bellman-Ford 계열 정점 수 작음 Floyd-Warshall 간선 수가 매우 적음 Dijkstra 여러 번 12. 음수 간선과 음수 사이클Floyd-Warshall의 강점 중 하나는 음수 간선을 허용한다는 점이다.즉, 간선 가중치가 음수여도 알고리즘 자체는 동작한다.하지만 음수 사이클이 있으면 문제가 달라진다.예:A -> B = 3B -> C = -5C -> A = 1사이클 총합이 음수라면,A -> B -> C -> A를 반복할수록 비용을 계속 줄일 수 있다.이 경우 “최단 거리”라는 개념 자체가 무너진다.음수 사이클 판별Floyd-Warshall 수행 후 아래를 확인한다.if (dist[i][i] < 0) { // i를 포함하는 음수 사이클 존재}이유: 자기 자신으로 돌아오는 비용이 음수라는 뜻 즉, 음수 사이클을 이용해 비용을 계속 줄일 수 있다는 뜻13. 경로 복원기본 Floyd-Warshall은 거리 값만 구한다.실제 이동 경로까지 복원하려면 next 배열을 함께 관리하면 된다.아이디어next[i][j]를 “i에서 j로 갈 때 첫 번째로 가야 할 정점”으로 정의한다.그러면 경로를 한 칸씩 따라가며 전체 경로를 복원할 수 있다.초기화간선이 있을 때:next[a][b] = b;자기 자신은 필요에 따라:next[i][i] = i;갱신i -> k -> j 경로가 더 좋다면,dist[i][j] = dist[i][k] + dist[k][j];next[i][j] = next[i][k];왜 next[i][k]를 넣는가? i에서 j로 가는 새 최단 경로의 첫 걸음은 결국 i에서 k로 가는 최단 경로의 첫 걸음과 같기 때문이다.경로 복원import java.util.*;public class Main { static final long INF = 1_000_000_000_000L; static List<Integer> getPath(int start, int end, int[][] next) { List<Integer> path = new ArrayList<>(); if (next[start][end] == 0) return path; // 도달 불가 int cur = start; path.add(cur); while (cur != end) { cur = next[cur][end]; path.add(cur); } return path; } public static void main(String[] args) { int n = 4; long[][] dist = new long[n + 1][n + 1]; int[][] next = new int[n + 1][n + 1]; for (int i = 1; i <= n; i++) { Arrays.fill(dist[i], INF); dist[i][i] = 0; } int[][] edges = { {1, 2, 4}, {2, 3, 3}, {3, 4, 2}, {1, 4, 20} }; for (int[] e : edges) { int a = e[0]; int b = e[1]; int cost = e[2]; if (cost < dist[a][b]) { dist[a][b] = cost; next[a][b] = b; } } for (int k = 1; k <= n; k++) { for (int i = 1; i <= n; i++) { if (dist[i][k] == INF) continue; for (int j = 1; j <= n; j++) { if (dist[k][j] == INF) continue; long newCost = dist[i][k] + dist[k][j]; if (newCost < dist[i][j]) { dist[i][j] = newCost; next[i][j] = next[i][k]; } } } } List<Integer> path = getPath(1, 4, next); System.out.println(path); // [1, 2, 3, 4] }}14. 경로 존재 여부 판별: Warshall 형태Floyd-Warshall은 거리 문제뿐 아니라 도달 가능 여부를 구할 때도 자주 쓰인다.이 경우 dist 대신 reachable 같은 boolean 배열을 사용하면 된다.정의:reachable[i][j] = i에서 j로 갈 수 있는가점화식:reachable[i][j] = reachable[i][j] || (reachable[i][k] && reachable[k][j]);boolean[][] reachable = new boolean[n + 1][n + 1];// 간선 입력reachable[a][b] = true;for (int k = 1; k <= n; k++) { for (int i = 1; i <= n; i++) { for (int j = 1; j <= n; j++) { if (reachable[i][k] && reachable[k][j]) { reachable[i][j] = true; } } }}이 버전은 아래 문제에서 특히 유용하다. 순위 비교 선후 관계 A가 B를 이길 수 있는가 선수 관계, 키 비교, 감염 가능 여부대표 예시는 BOJ 2458 같은 유형이다.15. 최소 사이클 문제에서 주의할 점여기서 많이 헷갈리는 포인트가 있다.잘못 외우기 쉬운 문장Floyd-Warshall 후 dist[i][i]의 최솟값이 최소 사이클이다이 말은 항상 맞지 않다.왜냐하면 보통 최단 거리 문제에서는 초기화할 때dist[i][i] = 0;으로 놓기 때문이다.이렇게 하면 일반적인 양수 사이클이 있어도 dist[i][i]는 계속 0으로 남는다.즉, 최단 거리 문제의 기본 초기화에서는 dist[i][i]로 최소 사이클을 바로 구하면 안 된다. dist[i][i] < 0은 음수 사이클 판별에만 직접적으로 의미가 있다.최소 사이클을 구하는 올바른 방법 1Floyd-Warshall이 끝난 뒤,min(dist[i][j] + dist[j][i]) (i != j)를 확인하는 방식이 자주 쓰인다.즉 i -> j로 갔다가 j -> i로 돌아오는 비용을 본다.최소 사이클을 구하는 올바른 방법 2문제 목적이 “사이클 길이” 자체라면, 대각선을 0이 아니라 INF로 두고 시작하는 방식도 가능하다. 다만 이 경우는 “최단 거리 기본 구현”과 의미가 달라지므로 문제 의도를 분명히 이해하고 써야 한다.이 부분은 실전에서 틀리기 쉬우므로 반드시 구분해야 한다.16. 자주 나오는 응용 유형1) 모든 쌍 최단 거리가장 전형적인 문제다.대표 예시:BOJ 11404핵심: dist[i][j] 출력 도달 불가일 때 출력 형식 확인 같은 간선 여러 개면 최소 비용 저장2) 경로 존재 여부거리 대신 boolean으로 처리한다.대표 예시: 키 비교 승패 관계 선수 관계3) 순위 결정 문제예:A > BB > C이면A > C를 자동으로 유도할 수 있다.즉 전이 관계를 계산하는 문제다.4) 음수 사이클 탐지거리 자체보다 “이 그래프에 모순이 있는가”를 묻는 문제에서 쓰인다.5) 경로 복원최단 거리뿐 아니라 실제 이동 경로까지 출력해야 할 때 사용한다.17. 구현할 때 자주 하는 실수1) 같은 간선이 여러 번 들어오는데 마지막 값으로 덮어씀잘못된 예:dist[a][b] = cost;권장:dist[a][b] = Math.min(dist[a][b], cost);2) INF 가드 없이 더함잘못된 예:dist[i][j] = Math.min(dist[i][j], dist[i][k] + dist[k][j]);권장:if (dist[i][k] == INF || dist[k][j] == INF) continue;dist[i][j] = Math.min(dist[i][j], dist[i][k] + dist[k][j]);3) 루프 순서를 바꿈Floyd-Warshall은 k가 가장 바깥 루프라는 점이 핵심이다.4) 무방향 그래프인데 한 방향만 넣음무방향이라면:dist[a][b] = Math.min(dist[a][b], cost);dist[b][a] = Math.min(dist[b][a], cost);5) int로 처리하다가 overflow정점 수와 간선 비용이 크면 long을 쓰는 편이 안전하다.6) 경로 복원에서 도달 불가 케이스를 처리하지 않음next[start][end] == 0 같은 조건을 먼저 체크해야 한다.7) 최소 사이클과 음수 사이클 판별을 혼동 dist[i][i] < 0은 음수 사이클 판별 최소 양수 사이클은 별도 계산이 필요18. 실전 판단 기준문제에서 아래 조합이 보이면 거의 Floyd-Warshall 쪽이다. 정점 수가 작다 모든 쌍이 필요하다 경유를 여러 번 할 수 있다 관계의 전이성을 계산해야 한다반대로 아래라면 다른 알고리즘을 먼저 본다. 시작점이 하나다 정점 수가 매우 크다 간선 수가 적다 최단 거리보다 연결 요소 관리가 중요하다정리 표: 상황 추천 알고리즘 가중치 없음, 한 시작점 BFS 가중치 있음, 한 시작점 Dijkstra 음수 간선, 한 시작점 Bellman-Ford 모든 정점 쌍 최단 거리 Floyd-Warshall 연결 여부만 빠르게 관리 Union-Find 19. 시험장용 최소 암기 버전정의:모든 정점 쌍 최단 거리점화식:dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])루프:for k for i for j초기화:dist[i][i] = 0간선 있으면 가중치없으면 INF복잡도:O(N^3), O(N^2)주의:INF 가드다중 간선 min 처리음수 사이클은 dist[i][i] < 020. 최종 요약Floyd-Warshall은 다음 문장으로 정리할 수 있다.모든 정점을 경유지로 하나씩 열어 보면서,모든 정점 쌍 사이의 최단 거리를 갱신하는 O(N^3) DP 알고리즘핵심만 다시 압축하면: 모든 쌍 최단 거리 문제에 사용 k를 바깥 루프로 둔다 점화식은 dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]) 초기화는 자기 자신 0, 간선 없으면 INF 음수 간선은 가능하지만 음수 사이클은 주의 경로 복원은 next 배열로 가능 경로 존재 여부 판별에도 그대로 응용 가능실전에서 N <= 200 정도이고 “모든 쌍”이 보이면 가장 먼저 떠올릴 알고리즘 중 하나다.
- Fenwick Tree Algorithm algorithm fenwick-tree Fenwick Tree Fenwick Tree펜윅 트리(Fenwick Tree, BIT)는 배열의 prefix sum과 점 업데이트를 빠르게 처리하는 자료구조다.한 줄로 요약하면 다음과 같다.누적합 전용으로 단순화한 세그먼트 트리 같은 구조세그먼트 트리보다 구현이 짧고,합 관련 문제에서 매우 자주 쓰인다.1. 언제 쓰는가아래 조합이면 펜윅 트리를 떠올릴 수 있다. 점 업데이트 + 구간 합 prefix sum이 자주 필요함 구현은 단순한 편이 좋음 세그먼트 트리까지는 과함즉:합 전용 업데이트 / 쿼리 문제에 특히 잘 맞는다.2. 핵심 아이디어펜윅 트리는 tree[i]가 어떤 구간의 합을 들고 있게 만든다.이 구간 크기는 lowbit(i)로 결정된다.lowbit(i) = i & -i즉 인덱스의 마지막 1비트가 자기 담당 구간 크기를 뜻한다.flowchart LR T1["tree[1]<br>[1..1]"] T2["tree[2]<br>[1..2]"] T3["tree[3]<br>[3..3]"] T4["tree[4]<br>[1..4]"] T5["tree[5]<br>[5..5]"] T6["tree[6]<br>[5..6]"] T7["tree[7]<br>[7..7]"] T8["tree[8]<br>[1..8]"] T1 --- T2 --- T3 --- T4 --- T5 --- T6 --- T7 --- T8 Q["sum(7) 경로"] --> T7 T7 --> T6 T6 --> T4 U["add(5, +v) 경로"] --> T5 T5 --> T6 T6 --> T8이 그림은 일반적인 부모-자식 트리를 그린 것이 아니라,펜윅 트리에서 각 tree[i]가 담당하는 블록과query / update가 실제로 밟는 인덱스 경로를 보여 준다. 합을 구할 때는 인덱스에서 lowbit을 빼며 왼쪽으로 이동하고 업데이트할 때는 인덱스에 lowbit을 더하며 오른쪽으로 이동한다예를 들어 sum(7)을 구하면:tree[7] + tree[6] + tree[4] = [7..7] + [5..6] + [1..4]처럼 블록을 합쳐서 [1..7]을 만든다.3. lowbit가 왜 중요한가예를 들어: 4 (100)의 lowbit는 4 6 (110)의 lowbit는 2 7 (111)의 lowbit는 1즉 각 인덱스가 담당하는 구간 길이가 다르다.예를 들어 1-based에서: tree[4]는 길이 4 구간 합 tree[6]는 길이 2 구간 합 tree[7]는 길이 1 구간 합같은 느낌으로 이해하면 된다.4. 작은 예시로 보기배열이 1-based라고 하자.idx: 1 2 3 4 5 6 7 8value: 5 2 7 3 6 1 4 8그러면 일부 tree[i]는 다음처럼 해석할 수 있다. tree[1] -> [1..1] tree[2] -> [1..2] tree[3] -> [3..3] tree[4] -> [1..4] tree[6] -> [5..6] tree[8] -> [1..8]즉 펜윅 트리는 구간이 규칙적으로 겹쳐 저장되는 구조다.여기서 중요한 점은,각 tree[i]가 서로 다른 길이의 구간을 저장하지만그 구간들이 prefix sum을 덮는 데 딱 맞게 설계되어 있다는 것이다.즉 펜윅 트리는 “임의의 prefix를 몇 개의 블록 합으로 표현하는 구조”라고 이해하면 좋다.5. prefix sum은 어떻게 구하나sum(idx)를 구할 때는: tree[idx]를 더하고 idx -= lowbit(idx)로 이동 0이 될 때까지 반복즉 큰 구간부터 필요한 만큼만 합친다.예를 들어 sum(6)이면: tree[6] 사용 tree[4] 사용 종료즉 [1..6]을 적은 수의 블록으로 덮는 방식이다.이 과정을 구간으로 쓰면:[1..6] = [5..6] + [1..4]이다.즉 펜윅 쿼리는 “큰 블록부터 필요한 만큼만 가져오는 과정”이다.6. 업데이트는 어떻게 되나add(idx, val)을 할 때는: tree[idx] += val idx += lowbit(idx)로 이동 범위를 넘어갈 때까지 반복즉 해당 원소를 포함하는 상위 구간들만 갱신한다.예를 들어 idx = 5면: tree[5] tree[6] tree[8]순으로 반영된다.왜 이 셋만 바뀌는가?이 노드들이 모두 5번 인덱스를 포함하는 구간을 대표하기 때문이다.즉 업데이트는:해당 원소를 포함하는 상위 블록들만 갱신한다고 이해하면 된다.왜 query와 update가 모두 O(log N)인가한 번 이동할 때마다 idx의 마지막 1비트가 제거되거나,그 비트만큼 더 큰 구간으로 점프한다.예를 들어 idx = 13 (1101)이면: query 경로: 13 -> 12 -> 8 -> 0 update 경로: 13 -> 14 -> 16 -> ...처럼 많아야 이진수 자릿수만큼만 움직인다.query와 update 경로 예시query(7): tree[7] + tree[6] + tree[4] (7→6→4, lowbit을 빼며 이동)update(5, +3): tree[5], tree[6], tree[8] 갱신 (5→6→8, lowbit을 더하며 이동)즉 펜윅 트리의 핵심은:모든 prefix를 몇 개 안 되는 블록 합으로 분해할 수 있게 만든다는 데 있다.초기 배열에서 트리를 만드는 가장 쉬운 방법처음 배열 arr가 이미 있다면,가장 단순한 build는 각 원소를 그대로 add 하는 것이다.Fenwick fenwick = new Fenwick(n);for (int i = 1; i <= n; i++) { fenwick.add(i, arr[i]);}이 방식은 O(N log N)이지만 구현이 직관적이고,코딩테스트에서는 대부분 충분하다.7. 전체 구현static class Fenwick { long[] tree; int n; Fenwick(int n) { this.n = n; tree = new long[n + 1]; } void add(int idx, long val) { while (idx <= n) { tree[idx] += val; idx += idx & -idx; } } long sum(int idx) { long ret = 0; while (idx > 0) { ret += tree[idx]; idx -= idx & -idx; } return ret; } long rangeSum(int left, int right) { return sum(right) - sum(left - 1); }}8. 왜 rangeSum(left, right)가 가능한가펜윅 트리는 기본적으로 prefix sum만 빠르게 구한다.하지만 구간 합은:[1..right] - [1..left-1]로 바꿀 수 있으므로,결국 prefix sum 두 번으로 처리 가능하다.즉:sum(right) - sum(left - 1)이면 된다.이 때문에 펜윅 트리는 prefix sum 전용 구조처럼 보이지만,실제로는 구간 합까지 자연스럽게 처리할 수 있다.추가로 “점 업데이트 + 구간 합”뿐 아니라“구간 업데이트 + 점 조회”도 차분 배열 관점과 결합하면 구현할 수 있다.다만 기본형은 가장 먼저 익히는 것이 중요하므로,코딩테스트에서는 보통 지금 문서의 형태를 기준으로 시작하면 충분하다.9. 세그먼트 트리와 비교 항목 Fenwick Segment Tree 구현 더 짧음 더 김 지원 연산 주로 합 더 다양 이해 난이도 비교적 쉬움 더 일반적 즉: 점 업데이트 + 합 -> Fenwick이 편함 min/max, 더 복잡한 쿼리 -> Segment Tree10. 자주 하는 실수1) 1-based 인덱스를 안 맞춤펜윅 트리는 보통 1-based로 구현한다.2) idx & -idx 의미를 못 외움이 연산이 핵심이다.3) 업데이트와 쿼리 방향을 반대로 구현 업데이트는 += lowbit 쿼리는 -= lowbit이 둘이 정확히 반대다.4) 합이 큰데 int 사용합 문제는 long이 안전하다.11. 시험장용 최소 암기 버전Fenwick Tree:prefix sum + point update핵심:lowbit = x & -xupdate:idx += lowbit(idx)query:idx -= lowbit(idx)range sum:sum(r) - sum(l - 1)12. 최종 요약펜윅 트리는 다음 문장으로 정리할 수 있다.prefix sum과 점 업데이트를 로그 시간에 처리하는 간단한 트리 구조문제를 보면 먼저 이 질문을 하면 된다.구간 합이 필요하지만,사실상 prefix sum 중심 문제인가?그렇다면 펜윅 트리가 매우 좋은 선택이 될 수 있다.
- Dynamic Programming Algorithm algorithm dynamic-programming Dynamic Programming Dynamic Programming동적 계획법(Dynamic Programming, DP)은 큰 문제를 작은 문제로 나누고, 같은 계산을 반복하지 않도록 저장하면서 푸는 기법이다.한 줄로 요약하면 다음과 같다.작은 문제의 답을 저장해 두고그 답으로 큰 문제를 만든다1. DP는 언제 쓰는가문제에서 아래 느낌이 나면 DP를 의심하면 된다. 경우의 수 최댓값 / 최솟값 i번째까지 봤을 때의 최적해 선택 / 비선택 이전 상태에 따라 현재 답이 결정됨 같은 부분 문제가 반복됨 완전탐색은 되지만 너무 느림대표 예시: 피보나치 수열 계단 오르기 배낭 문제 LIS, LCS 문자열 편집 거리 트리 DP 비트마스크 DP2. DP의 본질DP의 핵심은 두 가지다.1) 부분 문제 중복 Overlapping Subproblems같은 작은 문제를 여러 번 계산하게 된다.예:f(5)를 구할 때 f(4), f(3)이 필요하고f(4)를 구할 때 또 f(3), f(2)가 필요하다즉 f(3) 같은 계산이 반복된다.2) 최적 부분 구조 Optimal Substructure큰 문제의 최적해가 작은 문제의 최적해로부터 만들어진다.예:i번째까지의 최댓값은(i-1)번째까지의 최댓값을 바탕으로 만든다이 두 조건이 잘 맞으면 DP가 강력하다.3. 왜 DP가 필요한가완전탐색으로 모든 경우를 보려면 지수 시간이 걸리는 문제들이 많다.하지만 작은 문제의 답을 저장하면,같은 계산을 다시 하지 않아도 된다.예를 들어 피보나치를 단순 재귀로 풀면 매우 느리다.int fib(int n) { if (n <= 1) return n; return fib(n - 1) + fib(n - 2);}이 방식은 fib(3), fib(2) 등을 계속 다시 계산한다.flowchart TD F5["fib(5)"] --> F4["fib(4)"] F5 --> F3a["fib(3)"] F4 --> F3b["fib(3)"] F4 --> F2a["fib(2)"] F3a --> F2b["fib(2)"] F3a --> F1a["fib(1)"] F3b --> F2c["fib(2)"] F3b --> F1b["fib(1)"]위 그림을 보면 fib(3)이 두 번, fib(2)가 세 번 호출되는 것이 보인다.n이 커지면 이런 중복 호출이 기하급수적으로 늘어난다.반면 DP로 저장하면 각 값은 한 번만 계산한다.4. DP를 푸는 사고 순서DP 문제를 풀 때는 아래 순서가 가장 중요하다.1. dp[state]가 무엇을 의미하는가?2. 점화식은 무엇인가?3. 초기값은 무엇인가?4. 어떤 순서로 계산해야 하는가?5. 최종 답은 어디에 있는가?이 다섯 개가 정리되면 구현은 대부분 자연스럽게 따라온다.flowchart TD A["상태 정의"] --> B["점화식 작성"] B --> C["Base case 설정"] C --> D["채우기 순서 결정"] D --> E["테이블 채우기 또는 Memoization"] E --> F["최종 답 읽기"]DP는 결국 이 순서대로 정리하는 작업이다. 코드보다 먼저 이 흐름이 머릿속에서 정리되어야 점화식이 흔들리지 않는다.5. 상태 정의가 가장 중요하다DP에서 가장 어려운 것은 코드가 아니라 상태 정의다.예를 들어 계단 오르기 문제라면:dp[i] = i번째 계단까지 왔을 때의 최대 점수로 정의할 수 있다.배낭 문제라면:dp[i][w] = 앞에서 i개 물건만 고려했을 때 무게 한도 w에서 얻을 수 있는 최대 가치이처럼 상태는 다음을 담아야 한다. 지금 어디까지 왔는가 무엇을 알고 있는가 무엇이 답을 결정하는가상태 정의가 잘못되면 점화식도 꼬인다.6. 점화식이란 무엇인가점화식은 현재 상태를 더 작은 상태들로 표현한 식이다.예:dp[i] = max(dp[i - 1], dp[i - 2] + value[i])이 식은 현재 답이 이전 답들로부터 만들어진다는 뜻이다.DP의 본질은 사실상 이 한 문장이다.현재 답을 이전에 계산한 답들로 만든다7. 초기값 Base Case점화식만 있다고 끝이 아니다.초기값이 반드시 필요하다.예를 들어 피보나치:dp[0] = 0dp[1] = 1배낭 문제:dp[0][w] = 0초기값은 DP의 출발점이다.이걸 잘못 두면 전체가 틀린다.8. Top-Down과 Bottom-UpDP 구현 방식은 크게 두 가지다.1) Top-Down 메모이제이션재귀로 문제를 풀되,이미 계산한 값은 저장해 두고 다시 계산하지 않는다.int[] memo;int fib(int n) { if (n <= 1) return n; if (memo[n] != -1) return memo[n]; return memo[n] = fib(n - 1) + fib(n - 2);}장점: 점화식 그대로 쓰기 쉬움 문제 구조를 재귀적으로 보기 좋음단점: 재귀 호출 오버헤드 스택 깊이 문제 가능2) Bottom-Up 테이블 채우기작은 상태부터 차례대로 채운다.int[] dp = new int[n + 1];dp[0] = 0;dp[1] = 1;for (int i = 2; i <= n; i++) { dp[i] = dp[i - 1] + dp[i - 2];}장점: 반복문이라 안정적 계산 순서가 명확함실전에서는 보통 Bottom-Up을 더 많이 쓴다.9. 가장 기본 예제: 피보나치정의:dp[i] = i번째 피보나치 수점화식:dp[i] = dp[i - 1] + dp[i - 2]초기값:dp[0] = 0dp[1] = 1int fib(int n) { if (n <= 1) return n; int[] dp = new int[n + 1]; dp[0] = 0; dp[1] = 1; for (int i = 2; i <= n; i++) { dp[i] = dp[i - 1] + dp[i - 2]; } return dp[n];}이 문제는 DP의 가장 순수한 입문형이다.10. 1차원 DP의 대표 패턴다음과 같은 문제는 1차원 DP인 경우가 많다. i번째까지 왔을 때의 최댓값 i번째까지 고려했을 때의 경우의 수 앞에서부터 차례로 결정하는 문제대표 예시: 계단 오르기 집 털기 House Robber 동전 교환 연속합즉 축이 하나면 보통 dp[i] 형태를 먼저 생각하면 된다.11. 선택 / 비선택 패턴DP에서 가장 많이 나오는 사고다.예를 들어 어떤 원소를 고를지 말지 결정하는 문제라면,현재 선택이 이전 상태에 어떤 영향을 주는지만 보면 된다.예시:i번째 원소를 고른다 / 안 고른다이 패턴은 다음 문제에 자주 나온다. 집 털기 계단 문제 배낭 문제 트리 독립 집합12. 최대값 / 최소값 DP 예시: House Robber 형태문제:인접한 두 칸을 동시에 선택할 수 없을 때 최대 합정의:dp[i] = 0..i 구간에서 얻을 수 있는 최대 합점화식:dp[i] = max(dp[i - 1], dp[i - 2] + arr[i])왜냐하면: i를 안 고르면 dp[i - 1] i를 고르면 i - 1은 못 고르므로 dp[i - 2] + arr[i]int solve(int[] arr) { int n = arr.length; if (n == 1) return arr[0]; int[] dp = new int[n]; dp[0] = arr[0]; dp[1] = Math.max(arr[0], arr[1]); for (int i = 2; i < n; i++) { dp[i] = Math.max(dp[i - 1], dp[i - 2] + arr[i]); } return dp[n - 1];}13. 경우의 수 DP 예시: 계단 오르기 수문제:한 번에 1칸 또는 2칸 오를 수 있을 때n칸에 도달하는 방법 수정의:dp[i] = i칸에 도달하는 방법 수점화식:dp[i] = dp[i - 1] + dp[i - 2]이유: 마지막에 1칸 올라왔다면 dp[i - 1] 마지막에 2칸 올라왔다면 dp[i - 2]즉 경우의 수 DP도 결국 상태 정의와 점화식의 문제다.14. 2차원 DP는 언제 나오는가상태를 하나의 축으로 표현하기 부족할 때 2차원 DP가 나온다.예: 몇 번째 원소까지 봤는가 현재 용량은 얼마인가 문자열의 어디까지 비교했는가 좌표 (i, j)에 도달했는가대표 예시: 배낭 문제 LCS 격자 경로 문제15. 배낭 문제 0/1 Knapsack문제:각 물건은 한 번만 고를 수 있을 때무게 제한 W 안에서 최대 가치를 구하라정의:dp[i][w] = 앞의 i개 물건만 고려했을 때 무게 한도 w에서 얻을 수 있는 최대 가치여기서 i는 “고려한 물건 수”다.즉 현재 비교하는 물건의 실제 배열 인덱스는 i - 1이다.점화식: 앞에서 i개 물건 중 마지막 물건을 안 고름 앞에서 i개 물건 중 마지막 물건을 고름즉:dp[i][w] = max( dp[i - 1][w], dp[i - 1][w - weight[i - 1]] + value[i - 1])int knapsack(int[] weight, int[] value, int n, int W) { int[][] dp = new int[n + 1][W + 1]; for (int i = 1; i <= n; i++) { for (int w = 0; w <= W; w++) { dp[i][w] = dp[i - 1][w]; if (w >= weight[i - 1]) { dp[i][w] = Math.max(dp[i][w], dp[i - 1][w - weight[i - 1]] + value[i - 1]); } } } return dp[n][W];}이 문제는 “선택/비선택” DP의 대표다.손 계산 예시물건: (무게, 가치) = (2,3), (3,4), (4,5) W=5 w= 0 1 2 3 4 5i=0 0 0 0 0 0 0i=1(2,3) 0 0 3 3 3 3i=2(3,4) 0 0 3 4 4 7i=3(4,5) 0 0 3 4 5 7dp[2][5]: 무게 2 + 무게 3 = 5 ≤ W → 가치 3+4 = 7 ✓dp[3][5]: 물건3을 고르면 dp[2][1] + 5 = 5, 안 고르면 dp[2][5] = 7 → 더 큰 7 유지16. 문자열 DP 예시: LCSLCS(Longest Common Subsequence)는 두 문자열의 최장 공통 부분 수열 길이를 구하는 문제다.정의:dp[i][j] = 첫 문자열 앞 i개, 둘째 문자열 앞 j개를 봤을 때의 LCS 길이점화식: 문자가 같으면 대각선에서 +1 다르면 위/왼쪽 중 큰 값if a[i - 1] == b[j - 1] dp[i][j] = dp[i - 1][j - 1] + 1else dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])점화식 증명Case 1: a[i-1] == b[j-1] 일 때두 문자열의 현재 끝 문자가 같다.이 문자는 반드시 LCS에 포함시킬 수 있다.귀류법:이 문자를 포함하지 않는 더 긴 공통 부분 수열 L이 있다고 가정하자.L은 a의 앞 i개, b의 앞 j개 안에서 만들어졌으므로길이가 dp[i-1][j-1] + 1보다 클 수 없다.왜냐하면 a[i-1]과 b[j-1]을 빼면 남는 범위가 a의 앞 i-1개, b의 앞 j-1개이고그 범위의 LCS 최대 길이가 dp[i-1][j-1]이기 때문이다.따라서 dp[i][j] = dp[i-1][j-1] + 1이 최적이다.Case 2: a[i-1] != b[j-1] 일 때두 끝 문자가 다르므로 둘 다 동시에 LCS의 마지막이 될 수 없다.따라서 LCS는 다음 두 경우 중 하나에 반드시 속한다.경우 A: a[i-1]이 LCS에 포함되지 않음 → LCS는 a의 앞 i-1개와 b의 앞 j개로 결정경우 B: b[j-1]이 LCS에 포함되지 않음 → LCS는 a의 앞 i개와 b의 앞 j-1개로 결정두 경우가 모든 가능성을 빠짐없이 커버하므로dp[i][j] = max(dp[i-1][j], dp[i][j-1])이 문제는 “두 축을 동시에 따라가며 비교”하는 DP의 대표다.손 계산 예시a = "ABCB", b = "BDCB" "" B D C B "" 0 0 0 0 0 A 0 0 0 0 0 B 0 1 1 1 1 C 0 1 1 2 2 B 0 1 1 2 3a[1]='B' == b[0]='B' → dp[2][1] = dp[1][0] + 1 = 1 (최초 매치)a[2]='C' == b[2]='C' → dp[3][3] = dp[2][2] + 1 = 1+1 = 2a[3]='B' == b[3]='B' → dp[4][4] = dp[3][3] + 1 = 2+1 = 3LCS = "BCB", 길이 317. LIS 최장 증가 부분 수열LIS는 수열 DP의 대표 문제다.O(N^2) DP 정의dp[i] = i에서 끝나는 LIS 길이점화식:arr[j] < arr[i] 이면 dp[i] = max(dp[i], dp[j] + 1)점화식 증명dp[i]는 arr[i]를 마지막 원소로 하는 LIS의 길이다.1. 초기값: dp[i] = 1 원소 하나만으로도 길이 1인 증가 수열이 된다.2. 전이: j < i이고 arr[j] < arr[i]인 모든 j에 대해 dp[i] = max(dp[i], dp[j] + 1)왜 이것이 성립하는가?arr[i]로 끝나는 LIS를 생각하면그 직전 원소는 반드시 arr[i]보다 작고 인덱스가 i보다 앞이다.직전 원소가 arr[j]라면arr[j]로 끝나는 LIS 뒤에 arr[i]를 붙인 것이므로길이는 dp[j] + 1이 된다.가능한 모든 j 중 dp[j]가 가장 큰 것을 택하면arr[i]로 끝나는 가장 긴 증가 수열을 얻는다.최종 답이 max(dp[i])인 이유:LIS가 어떤 인덱스에서 끝날지 모르므로모든 위치에서 끝나는 LIS 중 최댓값이 전체 LIS 길이다.int lis(int[] arr) { int n = arr.length; int[] dp = new int[n]; Arrays.fill(dp, 1); int answer = 1; for (int i = 0; i < n; i++) { for (int j = 0; j < i; j++) { if (arr[j] < arr[i]) { dp[i] = Math.max(dp[i], dp[j] + 1); } } answer = Math.max(answer, dp[i]); } return answer;}LIS는 나중에 O(N log N) 최적화도 배우게 되지만,처음에는 DP 관점으로 이해하는 것이 중요하다. O(N log N) 방식 요약: tails 배열을 유지하면서, 새 원소가 tails 끝보다 크면 추가, 아니면 이진 탐색으로 대체할 위치를 찾는다. tails.length가 LIS 길이가 된다. N이 크면(10만 이상) 이 방식이 필수다.18. 점화식이 안 보일 때 질문해야 할 것DP가 막힐 때는 아래를 스스로 물어보면 된다. 마지막 행동은 무엇이었는가? 현재 상태를 결정하는 최소 정보는 무엇인가? 현재를 만들 수 있는 직전 상태는 무엇인가? 중복 계산이 생기는가? 답을 표로 저장할 수 있는가?특히 “마지막 행동”을 생각하는 것이 매우 강력하다.예: 마지막에 물건을 골랐는가? 마지막 문자가 같은가? 마지막 계단을 어떻게 왔는가?19. 메모리 최적화어떤 DP는 전체 테이블이 필요 없다.오직 직전 상태만 필요할 때는 공간을 줄일 수 있다.예:피보나치:int fib(int n) { if (n <= 1) return n; int a = 0; int b = 1; for (int i = 2; i <= n; i++) { int c = a + b; a = b; b = c; } return b;}즉 상태 전이가 오직 몇 개의 이전 값에만 의존하면,배열 전체를 들고 있을 필요가 없다.배낭 문제의 메모리 최적화: 토글링0/1 배낭의 2차원 DP를 다시 보면:dp[i][w] = max(dp[i-1][w], dp[i-1][w - weight[i - 1]] + value[i - 1])i번째 행을 채울 때 참조하는 것은 오직 i-1번째 행뿐이다.즉 전체 n개 행이 아니라 이전 행 하나만 있으면 된다.방법 1: 두 줄 토글링행 두 개만 번갈아 쓰는 방식이다.int knapsack(int[] weight, int[] value, int n, int W) { int[][] dp = new int[2][W + 1]; for (int i = 1; i <= n; i++) { int cur = i % 2; int prev = (i - 1) % 2; for (int w = 0; w <= W; w++) { dp[cur][w] = dp[prev][w]; if (w >= weight[i - 1]) { dp[cur][w] = Math.max(dp[cur][w], dp[prev][w - weight[i - 1]] + value[i - 1]); } } } return dp[n % 2][W];}공간이 O(N × W)에서 O(2 × W) = O(W)로 줄어든다.방법 2: 1차원 배열 + 역순 순회한 줄로 더 줄일 수 있다.핵심은 w를 큰 쪽에서 작은 쪽으로 순회하는 것이다.int knapsack(int[] weight, int[] value, int n, int W) { int[] dp = new int[W + 1]; for (int i = 1; i <= n; i++) { for (int w = W; w >= weight[i - 1]; w--) { dp[w] = Math.max(dp[w], dp[w - weight[i - 1]] + value[i - 1]); } } return dp[W];}왜 역순인가?정순(w = 0 → W)으로 돌면dp[w - weight[i - 1]]가 이미 현재 물건이 반영된 "현재 행" 값이다.즉 같은 물건을 여러 번 고르는 셈이 된다.역순(w = W → 0)으로 돌면dp[w - weight[i - 1]]는 아직 갱신 전이므로 "이전 행" 값이다.따라서 각 물건을 최대 한 번만 고르는 0/1 배낭이 유지된다. 참고: 물건을 여러 번 고를 수 있는 Unbounded Knapsack에서는 정순 순회가 맞다.20. 역추적 BacktrackingDP로 최적값을 구한 뒤, 그 값을 만든 실제 선택을 복원하는 것이 역추적이다.DP 테이블에는 “최적값”만 저장되어 있으므로,어떤 선택이 그 값을 만들었는지 거꾸로 따라가야 한다.핵심 아이디어:1. DP 테이블을 먼저 다 채운다2. 최종 답 위치에서 출발해 역방향으로 이동한다3. 각 위치에서 "어떤 전이가 현재 값을 만들었는가"를 판단한다4. 그 전이에 해당하는 선택을 기록한다LCS 역추적DP 테이블을 채운 뒤 (i, j) = (m, n)에서 출발한다.String traceLCS(String a, String b, int[][] dp) { int i = a.length(); int j = b.length(); StringBuilder sb = new StringBuilder(); while (i > 0 && j > 0) { if (a.charAt(i - 1) == b.charAt(j - 1)) { sb.append(a.charAt(i - 1)); i--; j--; } else if (dp[i - 1][j] >= dp[i][j - 1]) { i--; } else { j--; } } return sb.reverse().toString();}동작 원리:- 문자가 같으면: 이 문자는 LCS에 포함 → 기록하고 대각선 이동- 다르면: dp[i-1][j]와 dp[i][j-1] 중 큰 쪽으로 이동 (값이 줄어들지 않는 방향 = 선택이 발생하지 않은 방향)0/1 배낭 역추적어떤 물건을 골랐는지 복원한다.List<Integer> traceKnapsack(int[][] dp, int[] weight, int[] value, int n, int W) { List<Integer> selected = new ArrayList<>(); int w = W; for (int i = n; i >= 1; i--) { if (dp[i][w] != dp[i - 1][w]) { selected.add(i - 1); w -= weight[i - 1]; } } Collections.reverse(selected); return selected;}동작 원리:- dp[i][w] != dp[i-1][w]이면 배열 인덱스 i-1인 물건을 골랐다는 뜻이다. (안 골랐다면 값이 이전 행과 같아야 하므로)- 골랐으면 잔여 용량에서 해당 무게를 빼고 다음 물건으로 이동한다.LIS 역추적List<Integer> traceLIS(int[] arr, int[] dp) { int n = arr.length; int maxLen = 0; int maxIdx = 0; for (int i = 0; i < n; i++) { if (dp[i] > maxLen) { maxLen = dp[i]; maxIdx = i; } } List<Integer> result = new ArrayList<>(); result.add(arr[maxIdx]); int curLen = maxLen; for (int i = maxIdx - 1; i >= 0; i--) { if (dp[i] == curLen - 1 && arr[i] < arr[maxIdx]) { result.add(arr[i]); curLen--; maxIdx = i; } } Collections.reverse(result); return result;}동작 원리:- dp 값이 가장 큰 위치에서 출발한다.- 뒤에서 앞으로 가면서 dp 값이 정확히 1 작고 원소 값도 더 작은 위치를 찾아 이어 붙인다.역추적의 일반 원칙1. DP 테이블을 완성한 뒤에 수행한다2. 최종 답 위치에서 시작해 역방향으로 이동한다3. 각 칸에서 "이 값이 어디서 왔는가"만 확인한다4. 별도 choice 배열을 두면 역추적이 더 간단해진다 팁: 역추적이 복잡할 때는 DP를 채우면서 choice[i][j]에 전이 방향을 미리 기록해 두면 역추적 코드가 훨씬 단순해진다.21. 많은 DP는 Top-Down으로 시작하기 쉽다Top-Down의 핵심은 이것이다.많은 전형적 DP는상태와 종료 조건을 정한 뒤완전탐색에 memo를 붙여 구현할 수 있다즉 Top-Down은 사실 DP를 푸는 것이 아니라종종 완전탐색에 저장을 붙이는 방식으로 구현하는 것이다.왜 점화식을 먼저 식으로 쓰지 않아도 되는가Bottom-Up은 이런 흐름이다.1. 상태를 정의한다2. 점화식을 세운다3. 채우기 순서를 설계한다4. for문으로 테이블을 채운다이 중 2번과 3번이 어렵다. 점화식이 안 보이거나, 순서가 꼬이면 막힌다.Top-Down은 다르다.1. "이 상태에서 할 수 있는 선택이 뭐가 있지?"만 생각한다2. 각 선택을 재귀로 시도한다3. 결과를 저장한다점화식을 먼저 수식 형태로 적지 않아도 되는 경우가 많다.다만 상태 정의, 종료 조건, 메모이제이션 기준은 여전히 정확해야 한다.보통은 “지금 뭘 할 수 있는가”를 코드로 적다 보면 점화식이 자연스럽게 드러난다.flowchart TD A["현재 상태에서 시작"] --> B{"끝난 상태인가?"} B -- 예 --> C["기저값 반환"] B -- 아니오 --> D{"이미 계산했는가?"} D -- 예 --> E["저장된 값 반환"] D -- 아니오 --> F["가능한 선택지를 모두 시도"] F --> G["각 선택의 결과 중 최적값 선택"] G --> H["결과를 memo에 저장"] H --> I["저장된 값 반환"]3단계 접근: 완전탐색 → memo 추가 → 정리많은 전형적 DP는 이 순서로 접근할 수 있다.1단계: 완전탐색을 먼저 짠다점화식 같은 건 잊고 그냥 모든 경우를 재귀로 탐색하는 코드를 짠다.“이 상태에서 할 수 있는 선택은 무엇인가?”만 생각한다.2단계: memo 배열을 추가한다같은 상태를 두 번 이상 계산하지 않도록 결과를 저장한다.이것만으로 완전탐색이 DP가 된다.3단계: 상태를 점검하고 정리한다재귀가 필요한 순서를 자동으로 따라가므로Bottom-Up처럼 채우기 순서를 직접 설계할 부담은 줄어든다.다만 상태 정의나 기저 조건이 틀리면 Top-Down도 그대로 틀린다.템플릿 코드int[] memo; // 또는 int[][] memoint solve(상태) { // 1. 끝난 상태면 바로 반환 if (종료 조건) return 기저값; // 2. 이미 계산한 상태면 바로 반환 if (memo[상태] != -1) return memo[상태]; // 3. 할 수 있는 선택을 모두 시도 int result = 초기값; for (각 선택지) { result = 비교(result, solve(선택 후 상태)); } // 4. 저장하고 반환 return memo[상태] = result;} 주의: memo의 초기값으로 -1을 많이 쓰는데, 답이 -1일 수 있는 문제에서는 visited 배열을 별도로 두거나 Long.MIN_VALUE 같은 센티널을 쓴다.예시 1: 피보나치생각 과정:Q: fib(n)을 구하려면?A: fib(n-1)과 fib(n-2)를 더하면 된다.Q: 언제 끝나는가?A: n이 0이면 0, 1이면 1.이게 전부다. 점화식을 세운 게 아니라 “어떻게 구하지?”를 자연어로 답한 것뿐이다.int[] memo;int fib(int n) { if (n <= 1) return n; if (memo[n] != -1) return memo[n]; return memo[n] = fib(n - 1) + fib(n - 2);}예시 2: 0/1 배낭생각 과정:Q: 앞에서 i개 물건을 볼 수 있고 용량 w가 남았다. 뭘 할 수 있는가?A: 두 가지. - 이 물건을 안 고른다 → solve(i-1, w) - 이 물건을 고른다 → solve(i-1, w-weight[i-1]) + value[i-1] 둘 중 큰 값을 택한다.Q: 언제 끝나는가?A: 물건이 없으면 (i == 0) 가치는 0이다.점화식을 세운 것이 아니다. “이 순간 무엇을 할 수 있는가?”에 답한 것뿐이다.int[][] memo;int[] weight, value;int knapsack(int i, int w) { if (i == 0) return 0; if (memo[i][w] != -1) return memo[i][w]; // 선택 1: 안 고른다 int result = knapsack(i - 1, w); // 선택 2: 고른다 (용량이 되면) if (w >= weight[i - 1]) { result = Math.max(result, knapsack(i - 1, w - weight[i - 1]) + value[i - 1]); } return memo[i][w] = result;}예시 3: LCS생각 과정:Q: 문자열 a의 i번째, b의 j번째를 보고 있다. 뭘 할 수 있는가?A: 두 문자가 같으면 → 이건 공통이다. 둘 다 한 칸 전진 + 1 다르면 → a를 한 칸 버리거나, b를 한 칸 버리거나. 둘 중 큰 쪽.Q: 언제 끝나는가?A: 어느 한 쪽 문자열이 끝나면 0이다.int[][] memo;String a, b;int lcs(int i, int j) { if (i == 0 || j == 0) return 0; if (memo[i][j] != -1) return memo[i][j]; if (a.charAt(i - 1) == b.charAt(j - 1)) { return memo[i][j] = lcs(i - 1, j - 1) + 1; } return memo[i][j] = Math.max(lcs(i - 1, j), lcs(i, j - 1));}예시 4: LIS생각 과정:Q: arr[i]로 끝나는 가장 긴 증가 수열은?A: i보다 앞에 있고 arr[j] < arr[i]인 모든 j에 대해 "arr[j]로 끝나는 LIS 뒤에 arr[i]를 붙인다"를 시도한다. 그 중 가장 긴 것을 택한다.Q: 최소 길이는?A: 자기 자신만으로 길이 1이다.int[] memo;int[] arr;int lis(int i) { if (memo[i] != -1) return memo[i]; int result = 1; // 최소한 자기 자신 for (int j = 0; j < i; j++) { if (arr[j] < arr[i]) { result = Math.max(result, lis(j) + 1); } } return memo[i] = result;}// 최종 답: 모든 i에 대해 max(lis(i))정리: 완전탐색과 DP의 거리flowchart LR A["완전탐색\n(재귀로 모든 경우 시도)"] -- "memo 추가" --> B["Top-Down DP"] B -- "재귀를 반복문으로" --> C["Bottom-Up DP"]이 그림이 핵심이다. 완전탐색 → Top-Down: 같은 상태를 저장하는 memo를 붙이면 된다 Top-Down → Bottom-Up: 점화식과 채우기 순서를 설계해야 한다즉 Top-Down은 완전탐색에서 가장 가까운 DP다.전형적인 문제에서는 상태와 선택만 명확해도 구현으로 옮기기 쉽다.Bottom-Up과 Top-Down 비교 항목 Bottom-Up Top-Down 사고 방식 점화식을 세우고 표를 채운다 선택지를 나열하고 재귀한다 채우기 순서 직접 설계해야 함 자동으로 결정됨 구현 난이도 점화식과 순서 둘 다 필요 완전탐색에 memo를 붙여 시작하기 쉬움 속도 보통 약간 빠름 재귀 오버헤드 있음 필요한 상태만 계산 모든 상태를 채움 호출된 상태만 계산 스택 안전 깊이 제한 주의 언제 Top-Down이 유리한가1. 점화식이 바로 안 보일 때 → "지금 뭘 할 수 있는가?"만 적으면 된다2. 채우기 순서가 복잡하거나 비직관적일 때 → 재귀가 알아서 필요한 순서를 찾아간다3. 모든 상태를 채울 필요가 없을 때 → 실제 호출되는 상태만 계산한다 예: 트리 DP, 비트마스크 DP에서 도달 불가능한 상태가 많을 때4. 빠르게 구현해야 할 때 → 완전탐색 코드에 memo만 붙이면 되므로 실수가 적다Java에서 스택 크기 주의Top-Down의 가장 큰 약점은 재귀 깊이 제한이다.Java 기본 스택 크기는 약 512KB~1MB로, 재귀 깊이 약 1만~3만 정도다.n이 10만 이상이면 StackOverflowError가 날 수 있다.해결 방법:// 방법 1: 스레드로 스택 크기 지정public static void main(String[] args) { new Thread(null, () -> { // 여기서 Top-Down DP 호출 solve(); }, "main", 1 << 26).start(); // 64MB 스택}방법 2: Bottom-Up으로 전환상태 수가 매우 크고 거의 모든 상태를 방문해야 한다면Bottom-Up이 안전하다. 실전 팁: 시험에서 시간이 부족하면 일단 Top-Down으로 빠르게 구현하고, 스택 문제가 생기면 그때 Bottom-Up으로 바꾸는 전략이 효과적이다.22. DP와 그리디의 차이둘 다 최적화를 다루지만 다르다.그리디지금 당장 가장 좋아 보이는 선택을 한다DP가능한 상태를 저장하면서모든 필요한 선택을 체계적으로 비교한다예를 들어 어떤 문제가: 현재 최선 선택이 미래에도 항상 최선이다 -> 그리디 가능 현재 선택이 미래에 미치는 영향이 복잡하다 -> DP 가능성 높음즉 그리디가 안 보이면 DP를 생각하는 경우가 많다.23. 트리 DP와 비트마스크 DP도 결국 DP다DP는 배열 한 줄짜리 문제만 뜻하지 않는다.트리 DPdp[node]dp[node][state]형태로 트리 위에서 진행한다.비트마스크 DPdp[mask][last]처럼 방문 집합과 마지막 위치를 상태로 둔다.즉 DP의 본질은 자료 구조가 아니라:상태를 정의하고그 상태를 이전 상태들로 전이하는 것이다.24. 자주 하는 실수1) 상태 정의가 불완전함현재 답을 결정하는 정보가 상태에 다 들어 있어야 한다.2) 초기값을 잘못 둠특히 0, -INF, INF 중 무엇으로 초기화해야 하는지 주의해야 한다.3) 점화식은 맞는데 계산 순서를 틀림Bottom-Up에서는 이전 상태가 먼저 계산되어 있어야 한다.4) 최댓값 문제인데 기본값을 0으로 두면 안 되는 경우음수 값이 있을 수 있으면 초기값 설계를 더 조심해야 한다.5) 경우의 수 문제에서 int overflow경우의 수는 매우 빨리 커진다.long이나 mod 처리가 필요할 수 있다.6) 완전탐색을 억지로 DP로 착각함상태 수가 너무 크면 DP도 불가능하다.즉 상태 수와 전이 수를 같이 계산해야 한다.25. 실전 판단 기준문제를 보고 아래 조건이 보이면 DP를 먼저 의심하면 된다. 작은 답으로 큰 답을 만들 수 있다 이전 선택의 결과를 저장하면 다시 쓸 수 있다 최댓값, 최솟값, 경우의 수를 묻는다 완전탐색은 되지만 중복 계산이 많다 배열, 문자열, 트리, 부분집합 상태가 차례로 진행된다그리고 항상 다음을 적어 보면 된다.dp[...] = 무엇인가?이 한 줄이 잡히면 절반은 끝난다.26. 시험장용 최소 암기 버전DP:작은 문제 답을 저장해서 큰 문제 만들기순서:1. 상태 정의2. 점화식3. 초기값4. 계산 순서5. 최종 답 위치자주 나오는 형태:dp[i]dp[i][j]dp[node]dp[mask]대표 패턴:선택 / 비선택앞에서부터 진행문자열 두 축 비교서브트리 합치기27. 최종 요약DP는 다음 문장으로 정리할 수 있다.중복되는 작은 문제의 답을 저장해 두고그 답들로 큰 문제를 만드는 기법핵심만 다시 압축하면: DP의 본질은 상태 정의와 점화식이다 초기값과 계산 순서까지 맞아야 한다 1차원, 2차원, 트리, 비트마스크 등 형태는 다양하다 최댓값, 최솟값, 경우의 수 문제에서 특히 자주 나온다 문제를 보면 먼저 dp[...] = 무엇인가를 적어 본다DP 문제를 풀 때는 항상 이 질문을 하면 된다.현재 답을 결정하는 최소 정보는 무엇이고,그 정보를 이전 상태의 답으로 만들 수 있는가?이 질문의 답이 예라면 DP일 가능성이 높다.
- Divide and Conquer Algorithm algorithm divide-and-conquer Divide and Conquer Divide and Conquer분할 정복(Divide and Conquer)은 문제를 작은 부분으로 나누고, 각 부분을 재귀적으로 풀고, 결과를 합치는 알고리즘 패러다임이다.한 줄로 요약하면 다음과 같다.큰 문제를 쪼개고 → 재귀로 풀고 → 합친다1. 언제 쓰는가 상황 예시 정렬 병합 정렬, 퀵 정렬 탐색 이분 탐색 거듭제곱 분할 정복 거듭제곱 행렬 거듭제곱 피보나치 O(log N) 가장 가까운 두 점 Closest Pair 구간 문제 병합 정렬 기반 Inversion Count 2. 핵심 아이디어분할 정복의 세 단계:1. Divide : 문제를 2개 이상의 작은 문제로 분할2. Conquer : 각 부분 문제를 재귀적으로 해결3. Combine : 부분 결과를 합쳐 전체 답을 구성flowchart TD A["문제 (크기 N)"] --> B["분할"] B --> C["부분문제 1 (N/2)"] B --> D["부분문제 2 (N/2)"] C --> E["재귀적 해결"] D --> F["재귀적 해결"] E --> G["결과 병합"] F --> G G --> H["최종 답"]핵심 차이: 분할 정복 vs DP: 분할 정복은 부분 문제가 겹치지 않고, DP는 겹친다 분할 정복 vs 그리디: 분할 정복은 쪼갠 뒤 합치고, 그리디는 매 단계 최선을 선택3. 병합 정렬 Merge Sort분할 정복의 가장 대표적인 예시다.핵심 아이디어:배열을 반으로 나누고 → 각각 정렬하고 → 합친다int[] temp;void mergeSort(int[] arr, int left, int right) { if (left >= right) return; int mid = (left + right) / 2; mergeSort(arr, left, mid); mergeSort(arr, mid + 1, right); merge(arr, left, mid, right);}void merge(int[] arr, int left, int mid, int right) { int i = left, j = mid + 1, k = left; while (i <= mid && j <= right) { if (arr[i] <= arr[j]) { temp[k++] = arr[i++]; } else { temp[k++] = arr[j++]; } } while (i <= mid) temp[k++] = arr[i++]; while (j <= right) temp[k++] = arr[j++]; for (int idx = left; idx <= right; idx++) { arr[idx] = temp[idx]; }}시간 복잡도: O(N log N) (항상)flowchart TD A["[5, 2, 8, 1, 3, 7, 4, 6]"] --> B["[5, 2, 8, 1]"] A --> C["[3, 7, 4, 6]"] B --> D["[5, 2]"] B --> E["[8, 1]"] C --> F["[3, 7]"] C --> G["[4, 6]"] D --> H["[2, 5]"] E --> I["[1, 8]"] F --> J["[3, 7]"] G --> K["[4, 6]"] H --> L["[1, 2, 5, 8]"] I --> L J --> M["[3, 4, 6, 7]"] K --> M L --> N["[1, 2, 3, 4, 5, 6, 7, 8]"] M --> N분할은 위에서 아래로 진행되지만,실제로 정렬된 결과가 만들어지는 순간은 리프까지 내려간 뒤작은 정렬 결과를 아래에서 위로 병합해 올라올 때다.4. Inversion Count (역순쌍 세기)병합 정렬을 응용하면 역순쌍의 개수를 O(N log N)에 구할 수 있다.역순쌍: i < j인데 arr[i] > arr[j]인 쌍핵심 아이디어:merge 과정에서 왼쪽 부분의 원소가 오른쪽 부분의 원소보다 크면그 왼쪽 원소 이후의 모든 원소도 역순쌍을 형성한다long inversionCount;int[] temp;void mergeSortCount(int[] arr, int left, int right) { if (left >= right) return; int mid = (left + right) / 2; mergeSortCount(arr, left, mid); mergeSortCount(arr, mid + 1, right); mergeCount(arr, left, mid, right);}void mergeCount(int[] arr, int left, int mid, int right) { int i = left, j = mid + 1, k = left; while (i <= mid && j <= right) { if (arr[i] <= arr[j]) { temp[k++] = arr[i++]; } else { inversionCount += (mid - i + 1); // 핵심! temp[k++] = arr[j++]; } } while (i <= mid) temp[k++] = arr[i++]; while (j <= right) temp[k++] = arr[j++]; for (int idx = left; idx <= right; idx++) { arr[idx] = temp[idx]; }}mid - i + 1 이 핵심이다.왼쪽 포인터 i 뒤의 모든 원소가 arr[j]보다 크기 때문이다.예: 왼쪽 [2, 5, 7] 오른쪽 [3, 4, 8] i=0 j=0arr[i]=2 ≤ arr[j]=3 → 그대로, i++arr[i]=5 > arr[j]=3 → 역순쌍! count += (mid-i+1) = 2 (5와 3, 7과 3이 모두 역순쌍) ↑ i 뒤의 5, 7은 모두 3보다 크므로 한꺼번에 셈5. 퀵 정렬의 아이디어 Quick Select퀵 정렬 자체는 Arrays.sort()가 해주지만,K번째 원소를 O(N) 평균에 찾는 Quick Select는 알아둘 만하다.핵심 아이디어:피벗을 기준으로 파티션하면피벗의 최종 위치를 알 수 있다→ K번째가 피벗 왼쪽이면 왼쪽만 재귀→ K번째가 피벗 오른쪽이면 오른쪽만 재귀→ 피벗이 K번째면 바로 반환int quickSelect(int[] arr, int left, int right, int k) { if (left == right) return arr[left]; int pivotIdx = partition(arr, left, right); if (k == pivotIdx) return arr[k]; else if (k < pivotIdx) return quickSelect(arr, left, pivotIdx - 1, k); else return quickSelect(arr, pivotIdx + 1, right, k);}int partition(int[] arr, int left, int right) { int pivot = arr[right]; int i = left; for (int j = left; j < right; j++) { if (arr[j] <= pivot) { swap(arr, i, j); i++; } } swap(arr, i, right); return i;}시간 복잡도: 평균: O(N) 최악: O(N²) (피벗이 항상 최솟값/최댓값)Partition 과정 (pivot = arr[right] = 4):[7, 2, 1, 8, 6, 3, 5, 4] i pivot j→j=0: 7>4 → skipj=1: 2≤4 → swap(arr[0],arr[1]) → [2, 7, 1, 8, 6, 3, 5, 4], i=1j=2: 1≤4 → swap(arr[1],arr[2]) → [2, 1, 7, 8, 6, 3, 5, 4], i=2j=3: 8>4 → skipj=4: 6>4 → skipj=5: 3≤4 → swap(arr[2],arr[5]) → [2, 1, 3, 8, 6, 7, 5, 4], i=3j=6: 5>4 → skipswap(arr[3], pivot) → [2, 1, 3, 4, 6, 7, 5, 8] ↑ pivot은 최종 위치 36. 분할 정복 거듭제곱$a^n$을 O(log N)에 계산한다.a^n = (a^(n/2))² × a^(n%2)long power(long base, long exp, long mod) { long result = 1; base %= mod; while (exp > 0) { if ((exp & 1) == 1) result = result * base % mod; base = base * base % mod; exp >>= 1; } return result;}7. 행렬 거듭제곱분할 정복 거듭제곱을 행렬에 적용하면피보나치 수를 O(log N)에 구할 수 있다.핵심 아이디어:\[\begin{pmatrix} F_{n+1} \\ F_n \end{pmatrix} = \begin{pmatrix} 1 & 1 \\ 1 & 0 \end{pmatrix}^n \begin{pmatrix} 1 \\ 0 \end{pmatrix}\]static final long MOD = 1_000_000_007;long[][] multiply(long[][] A, long[][] B) { int n = A.length; long[][] C = new long[n][n]; for (int i = 0; i < n; i++) for (int j = 0; j < n; j++) for (int k = 0; k < n; k++) C[i][j] = (C[i][j] + A[i][k] * B[k][j]) % MOD; return C;}long[][] matPow(long[][] M, long exp) { int n = M.length; long[][] result = new long[n][n]; for (int i = 0; i < n; i++) result[i][i] = 1; // 단위 행렬 while (exp > 0) { if ((exp & 1) == 1) result = multiply(result, M); M = multiply(M, M); exp >>= 1; } return result;}// 피보나치 F(n) 구하기long fibonacci(long n) { if (n <= 1) return n; long[][] M = { {1, 1}, {1, 0} }; long[][] result = matPow(M, n - 1); return result[0][0];}이 기법은 다음과 같은 선형 점화식에도 적용할 수 있다.f(n) = a·f(n-1) + b·f(n-2) + ...→ 행렬로 변환 → O(log N) 계산graph TD A["M⁸"] --> B["(M⁴)²"] B --> C["((M²)²)²"] C --> D["곱셈 3번으로 M⁸ 계산"] style D fill:#ccffcc핵심은 지수를 1씩 줄이지 않고 절반씩 줄인다는 점이다.그래서 행렬 곱셈 자체는 무겁더라도 반복 횟수는 O(log N)으로 줄어든다.일반 방법: M을 8번 곱함 → 곱셈 7회분할 정복: M → M² → M⁴ → M⁸ → 곱셈 3회N=10⁹일 때 곱셈 30회로 완료8. 가장 가까운 두 점 Closest Pair2차원 평면에서 가장 가까운 두 점을 O(N log N)에 찾는 문제다.핵심 아이디어:1. x좌표 기준 정렬2. 배열을 반으로 나누어 각각 최소 거리 구함3. 중앙 선 근처만 추가 확인 (strip)flowchart TD A["x 좌표로 정렬"] --> B["왼쪽/오른쪽 분할"] B --> C["왼쪽 최근접 쌍 찾기"] B --> D["오른쪽 최근접 쌍 찾기"] C --> E["d = min(d_left, d_right)"] D --> E E --> F["중앙선 기준 d 이내 strip 검사"] F --> G["더 가까운 쌍 발견 시 갱신"] G --> H["최근접 거리 반환"]여기서 진짜 핵심은 strip 단계다.왼쪽과 오른쪽에서 이미 구한 최소 거리 d보다 더 가까운 쌍은중앙선 근처 폭 d 안에서만 새로 나타날 수 있기 때문이다.9. 분할 정복의 시간 복잡도 분석마스터 정리(Master Theorem)로 분석한다.$T(N) = a \cdot T(N/b) + O(N^c)$ 조건 결과 $\log_b a < c$ $O(N^c)$ $\log_b a = c$ $O(N^c \log N)$ $\log_b a > c$ $O(N^{\log_b a})$ 예시: 병합 정렬: $T(N) = 2T(N/2) + O(N)$ → $a=2, b=2, c=1$ → $\log_2 2 = 1 = c$ → O(N log N) 이분 탐색: $T(N) = T(N/2) + O(1)$ → $a=1, b=2, c=0$ → $\log_2 1 = 0 = c$ → O(log N) 카라츠바 곱셈: $T(N) = 3T(N/2) + O(N)$ → $a=3, b=2, c=1$ → $\log_2 3 ≈ 1.58 > 1$ → O(N^{1.58})10. DP와의 차이 분할 정복 DP 부분 문제가 독립적 부분 문제가 겹침 (중복) 메모이제이션 불필요 메모이제이션 필수 하향식 재귀 상향식 또는 하향식 예: 병합 정렬 예: 피보나치 분할 정복에서 부분 문제가 겹치면 그건 DP로 풀어야 한다.11. 자주 하는 실수1) 기저 조건을 안 세움if (left >= right) return; // 반드시 필요이걸 안 하면 무한 재귀 → 스택 오버플로우.2) 병합 시 임시 배열 할당을 매번 함temp 배열을 전역으로 한 번만 만들자. 매번 new하면 느리다.3) 행렬 거듭제곱에서 단위 행렬 초기화를 빠뜨림for (int i = 0; i < n; i++) result[i][i] = 1;4) Quick Select에서 피벗 선택이 최악인 경우정렬된 배열에서 마지막 원소를 피벗으로 쓰면 O(N²)이다.랜덤 피벗을 쓰면 평균적으로 O(N)이다.12. 시험장용 최소 암기 버전병합 정렬:mergeSort(left, mid) + mergeSort(mid+1, right) + mergeInversion Count:merge에서 arr[i] > arr[j]이면 count += (mid - i + 1)분할 정복 거듭제곱:while (exp > 0) { if (odd) result *= base; base *= base; exp >>= 1; }행렬 거듭제곱:matPow(M, exp) → 피보나치 O(log N)Quick Select:partition → 피벗 위치와 K 비교 → 한쪽만 재귀13. 최종 요약분할 정복은 다음 문장으로 정리할 수 있다.문제를 독립적인 부분으로 나누고재귀적으로 풀고결과를 합치는 패러다임문제를 보면 이 질문을 하면 된다."이 문제를 반으로 나누면각 부분을 풀고 합칠 수 있는가?"→ 그렇다면 분할 정복이다
- Dijkstra Algorithm algorithm dijkstra Dijkstra DijkstraDijkstra 알고리즘은 하나의 시작점에서 모든 정점까지의 최단 거리를 구하는 대표적인 알고리즘이다.한 줄로 요약하면 다음과 같다.현재까지 가장 가까운 정점을 하나씩 확정해 나가면서최단 거리 정보를 갱신하는 Greedy 기반 최단 경로 알고리즘1. 언제 쓰는가문제에서 아래 표현이 보이면 Dijkstra를 먼저 의심하면 된다. 한 시작점에서 모든 정점까지의 최소 비용 특정 출발 도시에서 다른 도시들까지의 최단 거리 가중치가 있는 그래프 비용이 음수가 아님 최소 시간, 최소 거리, 최소 비용대표 비교: 알고리즘 해결 대상 BFS 가중치 없는 그래프의 최단 거리 Dijkstra 음수 가중치가 없는 그래프에서 한 시작점 최단 거리 Bellman-Ford 음수 간선이 있을 수 있는 그래프에서 한 시작점 최단 거리 Floyd-Warshall 모든 정점 쌍 최단 거리 즉, BFS는 가중치가 모두 동일한 경우 Dijkstra는 가중치가 있지만 음수가 없는 경우 Floyd-Warshall은 시작점이 하나가 아니라 모든 정점인 경우2. 핵심 아이디어다익스트라의 핵심은 다음 한 문장이다.아직 확정되지 않은 정점들 중현재까지의 최단 거리가 가장 짧은 정점은이제 정말 최단 거리로 확정할 수 있다왜 이런 생각이 가능할까?가중치가 모두 0 이상이면, 지금 가장 짧은 후보보다 나중에 다른 정점을 돌아서 오는 경로가 더 짧아질 수 없다왜냐하면 중간에 간선을 한 번 더 지날 때마다 비용이 줄어들지 않고 같거나 늘어나기 때문이다.이 성질 덕분에 다익스트라는 Greedy 하게 동작할 수 있다.3. 어떤 문제를 푸는가다익스트라는 보통 다음 문제를 푼다.Single Source Shortest Path즉, 시작점 start가 하나 주어졌을 때: start -> 1 start -> 2 start -> 3 … start -> N각 정점까지의 최단 거리를 모두 구하는 문제다.예를 들어 시작점이 1번이면 dist[i]는 다음 의미를 가진다.dist[i] = 1번 정점에서 i번 정점까지의 최단 거리4. 필요한 자료구조다익스트라는 보통 아래 3개가 핵심이다.1) 인접 리스트각 정점에서 갈 수 있는 다음 정점들과 가중치를 저장한다.ArrayList<Edge>[] graph = new ArrayList[n + 1];graph[u]에는 u에서 출발하는 모든 간선이 들어간다.예:1 -> 2 (3)1 -> 4 (8)2 -> 3 (5)이면graph[1] = [(2,3), (4,8)]graph[2] = [(3,5)]2) 거리 배열 dist시작점에서 각 정점까지의 현재 최단 거리 후보를 저장한다.long[] dist = new long[n + 1];초기에는: 시작점은 0 나머지는 INF3) 우선순위 큐 PriorityQueue현재까지 발견한 정점들 중에서 가장 짧은 거리 후보를 먼저 꺼내기 위해 사용한다.PriorityQueue<State> pq = new PriorityQueue<>( (a, b) -> Long.compare(a.cost, b.cost));즉, 큐의 맨 앞에는 항상 “지금 가장 가까운 후보 정점”이 온다.5. 왜 우선순위 큐를 쓰는가다익스트라를 단순 배열로 구현할 수도 있다.하지만 그 경우 매 단계마다: 아직 방문하지 않은 정점 중 최단 거리 값이 가장 작은 정점을 선형 탐색해야 해서 느리다.우선순위 큐를 쓰면 이 작업을 훨씬 빠르게 처리할 수 있다.그래서 실전에서는 보통: 인접 리스트 우선순위 큐조합이 정석이다.6. 알고리즘 흐름전체 흐름은 다음과 같다. dist를 모두 INF로 초기화한다. 시작점의 거리를 0으로 둔다. 우선순위 큐에 (시작점, 0)을 넣는다. 큐에서 가장 가까운 정점을 하나 꺼낸다. 그 정점에서 갈 수 있는 모든 간선을 확인한다. 더 짧은 경로가 발견되면 dist를 갱신하고 큐에 다시 넣는다. 큐가 빌 때까지 반복한다.핵심 갱신은 다음 한 줄이다.if (dist[next] > dist[cur] + weight) { dist[next] = dist[cur] + weight;}이 과정을 간선 완화(Relaxation) 라고 부른다.flowchart TD A["시작 노드 삽입"] --> B["최소 비용 상태 추출"] B --> C{"이미 처리된 상태"} C -->|Yes| B C -->|No| D["인접 노드 갱신"] D --> E{"거리 개선됨"} E -->|Yes| F["갱신 후 삽입"] E -->|No| G["건너뛰기"] F --> B G --> B즉 다익스트라는 “가장 가까운 후보를 꺼내고, 오래된 정보는 버리고, 더 짧아진 이웃만 다시 큐에 넣는 과정”이라고 보면 된다.7. 간선 완화(Relaxation)란 무엇인가예를 들어 현재 cur 정점까지의 최단 거리 후보가 7이라고 하자.그리고 cur -> next 간선 비용이 3이라면:start -> cur -> next = 10이다.이 값이 기존 dist[next]보다 더 작으면,dist[next] = 10;으로 갱신한다.즉 다익스트라는 계속해서"이 정점을 거쳐 가는 게 더 이득인가?"를 반복해서 묻는 알고리즘이다.8. 작은 예시로 따라가기다음 그래프를 보자.1 -> 2 (2)1 -> 3 (5)2 -> 3 (1)2 -> 4 (2)3 -> 4 (3)4 -> 5 (1)시작점은 1이다.초기 상태dist[1] = 0dist[2] = INFdist[3] = INFdist[4] = INFdist[5] = INF우선순위 큐:(1, 0)1단계: 1을 꺼냄1에서 갈 수 있는 정점들을 확인한다. 1 -> 2 = 2 1 -> 3 = 5갱신 후:dist[2] = 2dist[3] = 5큐:(2, 2), (3, 5)2단계: 2를 꺼냄2에서 갈 수 있는 정점: 2 -> 3 = 1 2 -> 4 = 2새 거리 계산: 1 -> 2 -> 3 = 3 1 -> 2 -> 4 = 4기존과 비교: dist[3] = 5였는데 3이 더 짧으므로 갱신 dist[4] = INF였는데 4로 갱신현재 상태:dist[1] = 0dist[2] = 2dist[3] = 3dist[4] = 4dist[5] = INF3단계: 3을 꺼냄3에서 4로 가면: 1 -> 2 -> 3 -> 4 = 6그런데 이미 dist[4] = 4이므로 갱신하지 않는다.4단계: 4를 꺼냄4에서 5로 가면: 1 -> 2 -> 4 -> 5 = 5따라서:dist[5] = 5최종 결과:1 -> 1 = 01 -> 2 = 21 -> 3 = 31 -> 4 = 41 -> 5 = 5핵심은 1 -> 3의 직접 경로 5보다1 -> 2 -> 3 = 3이 더 짧다는 점이 자동으로 반영된다는 것이다.예시 그래프 구조graph LR 1 -->|2| 2 1 -->|5| 3 2 -->|1| 3 2 -->|2| 4 3 -->|3| 4 4 -->|1| 5이 그래프에서는 1 -> 3 직접 가는 비용 5보다1 -> 2 -> 3으로 우회하는 비용 3이 더 짧다.다익스트라는 이런 우회 경로를 간선 완화로 자연스럽게 찾아낸다.9. 기본 구현실전에서 바로 쓸 수 있는 정석 구현이다.import java.util.*;public class Main { static class Edge { int to; int weight; Edge(int to, int weight) { this.to = to; this.weight = weight; } } static class State { int node; long cost; State(int node, long cost) { this.node = node; this.cost = cost; } } static final long INF = 1_000_000_000_000L; static ArrayList<Edge>[] graph; static long[] dist; static void dijkstra(int start) { PriorityQueue<State> pq = new PriorityQueue<>( (a, b) -> Long.compare(a.cost, b.cost) ); Arrays.fill(dist, INF); dist[start] = 0; pq.offer(new State(start, 0)); while (!pq.isEmpty()) { State cur = pq.poll(); // 오래된 정보면 무시 if (cur.cost > dist[cur.node]) continue; for (Edge edge : graph[cur.node]) { long newCost = dist[cur.node] + edge.weight; if (newCost < dist[edge.to]) { dist[edge.to] = newCost; pq.offer(new State(edge.to, newCost)); } } } } public static void main(String[] args) { int n = 5; graph = new ArrayList[n + 1]; dist = new long[n + 1]; for (int i = 1; i <= n; i++) { graph[i] = new ArrayList<>(); } graph[1].add(new Edge(2, 2)); graph[1].add(new Edge(3, 5)); graph[2].add(new Edge(3, 1)); graph[2].add(new Edge(4, 2)); graph[3].add(new Edge(4, 3)); graph[4].add(new Edge(5, 1)); dijkstra(1); for (int i = 1; i <= n; i++) { if (dist[i] == INF) { System.out.println(i + ": INF"); } else { System.out.println(i + ": " + dist[i]); } } }}10. 왜 if (cur.cost > dist[cur.node]) continue;가 필요한가다익스트라에서 우선순위 큐에는 같은 정점이 여러 번 들어갈 수 있다.예를 들어: 정점 3이 비용 10으로 큐에 들어감 나중에 더 좋은 경로를 찾아 비용 7로 또 들어감그러면 큐 안에는:(3, 10)(3, 7)둘 다 존재할 수 있다.이때 먼저 (3, 7)이 나와서 dist[3] = 7로 확정된 뒤,나중에 (3, 10)이 나오면 이것은 오래된 정보다.그래서:if (cur.cost > dist[cur.node]) continue;로 버린다.이 한 줄이 없으면 쓸데없는 연산이 크게 늘어난다.이 방식은 실전에서 매우 자주 쓰는 정석 패턴이다.11. visited 배열을 쓰는 방식과 차이다익스트라는 두 가지 스타일로 자주 구현한다.방식 1. visited 사용정점을 처음 꺼냈을 때 방문 확정 처리한다.if (visited[cur]) continue;visited[cur] = true;방식 2. 오래된 정보 건너뛰기if (cur.cost > dist[cur.node]) continue;둘 다 많이 쓴다.실전에서는 보통 오래된 정보 건너뛰기 방식이 더 간단하고 유연하다.이유: visited 없이도 구현 가능 더 짧은 경로가 다시 들어오는 상황을 자연스럽게 처리 경로 복원 코드와도 잘 맞음12. 초기화에서 주의할 점다익스트라 초기화는 다음이 기본이다.Arrays.fill(dist, INF);dist[start] = 0;그리고 시작점을 우선순위 큐에 넣는다.pq.offer(new State(start, 0));자주 하는 실수: dist[start] = 0을 안 함 INF를 너무 작게 둠 int로 두었다가 거리 합 overflow 발생안전하게 가려면 보통 long을 쓰는 편이 낫다.13. 인접 리스트를 왜 쓰는가다익스트라는 대부분 인접 리스트로 구현한다.이유: 현재 정점에서 나가는 간선만 확인하면 되기 때문 인접 행렬로 하면 필요 없는 간선까지 다 훑게 됨예를 들어 정점이 10000개인데 간선은 20000개 정도면 희소 그래프다.이때 인접 행렬은 10000 x 10000이라 매우 비효율적이다.반면 인접 리스트는 실제 존재하는 간선만 저장하므로 훨씬 적합하다.14. 시간 복잡도우선순위 큐 + 인접 리스트 기준 다익스트라의 시간 복잡도는 보통 다음처럼 본다.O((V + E) log V)직관적으로 보면: 정점과 간선을 한 번씩 확인하고 우선순위 큐 삽입/삭제마다 log V가 붙는다고 생각하면 된다.배열 기반 다익스트라는:O(V^2)이라서 정점 수가 작고 그래프가 조밀할 때만 고려할 만하다.실전에서는 대부분 우선순위 큐 버전을 쓴다.15. 다익스트라가 성립하는 이유다익스트라가 맞으려면 가장 중요한 전제가 있다.간선 가중치가 음수가 아니어야 한다왜냐하면 다익스트라는"현재 가장 가까운 정점은 이제 확정해도 된다"를 믿고 진행하는데,음수 간선이 있으면 나중에 멀리 돌아갔다가 오히려 더 짧아질 수 있기 때문이다.즉, Greedy의 근거가 사라진다.16. 왜 음수 간선에서 표준 다익스트라가 실패하는가예를 들어 다음 상황을 보자.1 -> 2 = 21 -> 3 = 53 -> 2 = -102 -> 4 = 1표준 다익스트라(visited로 꺼낸 정점을 확정하는 버전)는먼저 2를 비용 2로 확정하고,이어 4를 비용 3으로 만들 수 있다.하지만 실제로는:1 -> 3 -> 2 = -51 -> 3 -> 2 -> 4 = -4즉 먼저 꺼냈던 2가 나중에 더 짧아질 수 있으므로,“큐에서 꺼낸 순간 최단 거리 확정”이라는 다익스트라의 핵심 논리가 깨진다.현재 문서의 구현처럼 오래된 정보 스킵 + 재삽입 방식은음수 간선이 있어도 일부 입력에서는 정답이 나올 수 있다.하지만 이 경우는 더 이상 다익스트라의 정당성/복잡도 보장을 그대로 쓸 수 없고,음수 사이클이 있으면 갱신이 끝나지 않을 수도 있다.그래서 실전에서는 규칙을 단순하게 가져가면 된다. 음수 간선이 없다 -> Dijkstra 음수 간선이 있다 -> Bellman-Ford 등 다른 알고리즘대신: Bellman-Ford SPFA Floyd-Warshall같은 다른 접근을 봐야 한다.17. 경로 복원최단 거리 값만이 아니라 실제 경로까지 알고 싶다면 prev 배열을 둔다.정의:prev[next] = next로 오기 직전의 정점즉, 어떤 정점이 더 좋은 거리로 갱신될 때그 직전 정점이 누구였는지를 기록한다.예:if (newCost < dist[edge.to]) { dist[edge.to] = newCost; prev[edge.to] = cur.node; pq.offer(new State(edge.to, newCost));}그 뒤 목적지에서 시작점 쪽으로 역추적하면 된다.경로 복원import java.util.*;public class Main { static class Edge { int to; int weight; Edge(int to, int weight) { this.to = to; this.weight = weight; } } static class State { int node; long cost; State(int node, long cost) { this.node = node; this.cost = cost; } } static final long INF = 1_000_000_000_000L; static ArrayList<Edge>[] graph; static long[] dist; static int[] prev; static void dijkstra(int start) { PriorityQueue<State> pq = new PriorityQueue<>( (a, b) -> Long.compare(a.cost, b.cost) ); Arrays.fill(dist, INF); Arrays.fill(prev, -1); dist[start] = 0; pq.offer(new State(start, 0)); while (!pq.isEmpty()) { State cur = pq.poll(); if (cur.cost > dist[cur.node]) continue; for (Edge edge : graph[cur.node]) { long newCost = dist[cur.node] + edge.weight; if (newCost < dist[edge.to]) { dist[edge.to] = newCost; prev[edge.to] = cur.node; pq.offer(new State(edge.to, newCost)); } } } } static List<Integer> getPath(int start, int end) { List<Integer> path = new ArrayList<>(); if (dist[end] == INF) return path; for (int cur = end; cur != -1; cur = prev[cur]) { path.add(cur); } Collections.reverse(path); return path; }}예를 들어 1 -> 5 경로가1 -> 2 -> 4 -> 5라면 prev는 대략 다음 느낌으로 채워진다.prev[2] = 1prev[4] = 2prev[5] = 4따라서 5부터 거꾸로 따라 올라가면 전체 경로를 복원할 수 있다.18. 목표 정점 하나만 필요할 때시작점에서 모든 정점까지의 거리가 아니라특정 목적지 하나까지의 거리만 필요할 때도 있다.이 경우 다익스트라를 돌리다가목적지 정점이 우선순위 큐에서 꺼내지는 순간종료할 수 있다.이 시점에서는 그 정점의 거리가 최단 거리로 확정되었기 때문이다.즉, 모든 정점이 필요하면 끝까지 수행 하나의 목적지만 필요하면 조기 종료 가능19. 무방향 그래프와 방향 그래프입력 처리에서 자주 헷갈리는 부분이다.방향 그래프graph[a].add(new Edge(b, cost));무방향 그래프graph[a].add(new Edge(b, cost));graph[b].add(new Edge(a, cost));무방향 그래프인데 한 방향만 넣으면 결과가 완전히 틀어진다.20. 같은 두 정점 사이에 여러 간선이 있을 때예를 들어:1 -> 2 (10)1 -> 2 (3)둘 다 존재할 수 있다.이 경우는 두 간선을 모두 인접 리스트에 넣어도 다익스트라는 정상 동작한다.왜냐하면 완화 과정에서 더 짧은 쪽이 결국 채택되기 때문이다.다만 미리 줄이고 싶다면 입력 시 더 작은 간선만 남기는 방법도 있다.실전에서는 그냥 모두 넣고 다익스트라를 돌려도 충분한 경우가 많다.21. 암시적 그래프(Implicit Graph)에서도 가능하다모든 문제에서 간선을 미리 graph[u]에 저장할 필요는 없다.어떤 문제는 간선이 공식으로 주어진다.예: 좌표 차이로 이동 비용 계산 상태 전이 비용을 즉석 계산 원형 구조에서 상대 거리 기반 이동이 경우에는 인접 리스트를 만들지 않고현재 상태에서 다음 상태를 필요한 순간에만 계산할 수 있다.예를 들어:for (int next = 0; next < n; next++) { if (next == cur) continue; int cost = calc(cur, next); if (cost == -1) continue; if (dist[next] > dist[cur] + cost) { dist[next] = dist[cur] + cost; pq.offer(new State(next, dist[next])); }}이 방식은 그래프를 명시적으로 저장하지 않는 다익스트라다.다만 이 경우는 정점 하나를 꺼낼 때마다 모든 후보를 훑을 수 있어서,시간 복잡도가 더 나빠질 수 있다.즉, 간선을 저장하기 비효율적이면 유용 대신 매번 다음 상태를 계산하는 비용을 고려해야 함22. Floyd-Warshall과 비교다익스트라와 Floyd-Warshall은 자주 비교된다. 상황 추천 시작점이 하나 Dijkstra 모든 정점 쌍이 필요 Floyd-Warshall 정점 수가 작다 Floyd-Warshall 고려 그래프가 희소하다 Dijkstra 유리 음수 간선이 있다 Dijkstra 불가 핵심 차이: Dijkstra는 한 출발점 기준 Floyd-Warshall은 모든 출발점 기준즉, 시작점이 하나면 Dijkstra가 보통 더 자연스럽고 빠르다.23. Bellman-Ford와 비교둘 다 한 시작점 최단 거리 알고리즘이지만 차이가 크다. 항목 Dijkstra Bellman-Ford 음수 간선 불가 가능 속도 빠름 느림 핵심 아이디어 Greedy + PQ 모든 간선 반복 완화 그래서 보통은: 음수 간선이 없으면 Dijkstra 음수 간선이 있을 수 있으면 Bellman-Ford로 생각하면 된다.24. 자주 하는 실수1) 음수 간선인데 Dijkstra 사용가장 위험한 실수다.2) PriorityQueue 정렬 기준을 잘못 둠최소 비용이 먼저 나와야 한다.PriorityQueue<State> pq = new PriorityQueue<>( (a, b) -> Long.compare(a.cost, b.cost));3) Edge의 가중치와 State의 현재 거리 의미를 섞음간선의 weight와큐에 넣는 현재까지의 cost는 다른 값이다.이 둘을 같은 개념으로 혼동하면 버그가 자주 난다.4) 오래된 정보 스킵을 안 함if (cur.cost > dist[cur.node]) continue;를 빼면 시간 낭비가 커진다.5) int overflow거리 합이 커지면 long을 써야 한다.6) 무방향 그래프인데 양쪽 간선을 안 넣음7) 시작점 초기화를 빼먹음dist[start] = 0;8) 도달 불가 출력 처리를 안 함if (dist[i] == INF) System.out.println("INF");25. 시험장용 최소 암기 버전정의:한 시작점에서 모든 정점까지 최단 거리조건:간선 가중치가 음수면 안 됨핵심:현재 가장 가까운 정점을 먼저 확정자료구조:dist 배열인접 리스트PriorityQueue완화:if (dist[next] > dist[cur] + w) dist[next] = dist[cur] + w정석 체크:if (poll된 cost > dist[node]) continue복잡도:O((V + E) log V)26. 최종 요약Dijkstra는 다음 문장으로 정리할 수 있다.음수 가중치가 없는 그래프에서,현재까지 가장 가까운 정점을 하나씩 꺼내며최단 거리 후보를 갱신하는 알고리즘핵심만 다시 압축하면: 한 시작점 최단 거리 문제에 사용 우선순위 큐로 가장 가까운 정점을 먼저 꺼냄 완화 식은 dist[next] > dist[cur] + weight 오래된 정보는 continue 음수 간선이 있으면 쓰면 안 됨 경로 복원은 prev 배열로 가능실전에서는 가중치가 있고 음수 간선이 없고 시작점이 하나이 세 조건이 보이면 가장 먼저 떠올릴 알고리즘이다.
- Deque Algorithm algorithm deque sliding-window Deque Deque덱(Deque, Double-Ended Queue)은 양쪽 끝에서 삽입과 삭제가 모두 O(1)인 자료구조다.한 줄로 요약하면 다음과 같다.앞에서도 넣고 빼고, 뒤에서도 넣고 빼는 큐코테에서 덱은 주로 슬라이딩 윈도우 최솟값/최댓값과 0-1 BFS에 쓰인다.1. 언제 쓰는가 상황 이유 슬라이딩 윈도우 최솟값/최댓값 모노톤 덱으로 O(N) 풀이 0-1 BFS 가중치가 0 또는 1인 그래프 최단 경로 양방향 큐가 필요할 때 앞뒤 모두 접근 앞/뒤에서 번갈아 처리하는 시뮬레이션 회전 큐, 카드 문제처럼 양끝 조작이 필요할 때 2. Java Deque 기본 사용법Java에서 Deque 인터페이스는 ArrayDeque로 구현한다.주요 메서드 메서드 설명 위치 offerFirst(e) 앞에 삽입 Front offerLast(e) 뒤에 삽입 Back pollFirst() 앞에서 제거 Front pollLast() 뒤에서 제거 Back peekFirst() 앞 원소 확인 Front peekLast() 뒤 원소 확인 Back Deque<Integer> deque = new ArrayDeque<>();deque.offerLast(1); // [1]deque.offerLast(2); // [1, 2]deque.offerFirst(0); // [0, 1, 2]deque.peekFirst(); // 0deque.peekLast(); // 2deque.pollFirst(); // 0 제거, [1, 2]deque.pollLast(); // 2 제거, [1]Stack 대용으로 쓰기Stack 대신 ArrayDeque를 쓰는 것이 더 빠르다.Deque<Integer> stack = new ArrayDeque<>();stack.push(1); // offerFirststack.push(2); // offerFirststack.pop(); // pollFirst → 2stack.peek(); // peekFirst → 1Queue 대용으로 쓰기Deque<Integer> queue = new ArrayDeque<>();queue.offer(1); // offerLastqueue.offer(2); // offerLastqueue.poll(); // pollFirst → 1flowchart LR subgraph Deque direction LR F["앞쪽"] --- M["... 요소들 ..."] --- B["뒤쪽"] end I1["offerFirst"] --> F F --> O1["pollFirst"] I2["offerLast"] --> B B --> O2["pollLast"]즉 덱 하나로 앞뒤 삽입과 삭제를 모두 처리할 수 있어서,큐처럼도 쓰고 스택처럼도 쓰는 패턴이 자연스럽게 나온다.3. 모노톤 덱 Monotone Deque모노톤 덱은 덱 안의 원소가 단조 증가 또는 단조 감소를 유지하는 기법이다.슬라이딩 윈도우 문제에서 매우 강력하다.핵심 아이디어:덱에는 인덱스를 저장한다새 원소를 넣을 때, 덱 뒤에서부터 새 원소보다크거나 같은(최솟값 덱) 것들을 모두 제거한 뒤 삽입→ 덱의 앞이 항상 현재 윈도우의 최솟값4. 슬라이딩 윈도우 최솟값크기 K인 슬라이딩 윈도우가 배열 위를 이동하면서 각 위치의 최솟값을 구하는 문제다.브루트포스: O(NK)매 위치마다 K개를 순회한다.모노톤 덱: O(N)int[] slidingWindowMin(int[] arr, int k) { int n = arr.length; int[] result = new int[n - k + 1]; Deque<Integer> deque = new ArrayDeque<>(); // 인덱스 저장 for (int i = 0; i < n; i++) { // 1. 윈도우 밖의 원소 제거 while (!deque.isEmpty() && deque.peekFirst() <= i - k) { deque.pollFirst(); } // 2. 뒤에서부터 현재 값보다 크거나 같은 것 제거 while (!deque.isEmpty() && arr[deque.peekLast()] >= arr[i]) { deque.pollLast(); } // 3. 현재 인덱스 삽입 deque.offerLast(i); // 4. 결과 기록 (윈도우가 완성된 후) if (i >= k - 1) { result[i - k + 1] = arr[deque.peekFirst()]; } } return result;}손 계산 예시arr = [3, 1, 4, 1, 5, 9, 2, 6], K = 3i=0: deque=[0] → 윈도우 미완성i=1: arr[1]=1 < arr[0]=3 → 0 제거, deque=[1] → 윈도우 미완성i=2: arr[2]=4 > arr[1]=1 → deque=[1,2] → 결과: arr[1]=1i=3: arr[3]=1 ≤ arr[2]=4 → 2 제거 arr[3]=1 ≤ arr[1]=1 → 1 제거, deque=[3] → 결과: arr[3]=1i=4: arr[4]=5 > arr[3]=1 → deque=[3,4] → 결과: arr[3]=1i=5: 윈도우 범위 [3,4,5] front=3, 5-3=2 < K=3 → 남음 arr[5]=9 > arr[4]=5 → deque=[3,4,5] → 결과: arr[3]=1i=6: 윈도우 범위 [4,5,6] front=3, 6-3=3 ≥ K=3 → 3 제거 arr[6]=2 < arr[5]=9 → 5 제거 arr[6]=2 < arr[4]=5 → 4 제거, deque=[6] → 결과: arr[6]=2i=7: 윈도우 범위 [5,6,7] arr[7]=6 > arr[6]=2 → deque=[6,7] → 결과: arr[6]=2최종 결과: [1, 1, 1, 1, 2, 2]flowchart TD A["i = 0..N-1 반복"] --> B{"앞쪽이 윈도우 밖?<br/>(front ≤ i - K)"} B -->|Yes| C["pollFirst()"] C --> B B -->|No| D{"뒤쪽 값 ≥ arr[i]?"} D -->|Yes| E["pollLast()"] E --> D D -->|No| F["offerLast(i)"] F --> G{"i ≥ K - 1?"} G -->|Yes| H["결과 = arr[deque.peekFirst()]"] G -->|No| A H --> A여기서 덱에 값이 아니라 인덱스를 넣는 이유는,윈도우 범위를 벗어났는지와 현재 값과의 대소 비교를 동시에 처리해야 하기 때문이다.5. 슬라이딩 윈도우 최댓값최솟값과 방향만 반대다.// 뒤에서부터 현재 값보다 작거나 같은 것 제거 (최솟값과 반대)while (!deque.isEmpty() && arr[deque.peekLast()] <= arr[i]) { deque.pollLast();}6. 0-1 BFS간선 가중치가 0 또는 1인 그래프에서 최단 경로를 구하는 기법이다.일반 BFS는 가중치 없는 그래프에서 동작하고,다익스트라는 양의 가중치 그래프에서 동작하지만,0-1 BFS는 덱을 써서 O(V + E)에 최단 경로를 구한다.핵심 아이디어가중치가 0인 간선 → 덱 앞에 넣음 (offerFirst)가중치가 1인 간선 → 덱 뒤에 넣음 (offerLast)이렇게 하면 BFS 순서가 자연스럽게 최단 거리 순이 된다.int[] bfs01(int n, List<int[]>[] graph, int start) { // graph[u] = list of {v, weight} where weight is 0 or 1 int[] dist = new int[n + 1]; Arrays.fill(dist, Integer.MAX_VALUE); dist[start] = 0; Deque<Integer> deque = new ArrayDeque<>(); deque.offerFirst(start); while (!deque.isEmpty()) { int u = deque.pollFirst(); for (int[] edge : graph[u]) { int v = edge[0], w = edge[1]; if (dist[u] + w < dist[v]) { dist[v] = dist[u] + w; if (w == 0) { deque.offerFirst(v); // 비용 0: 앞에 } else { deque.offerLast(v); // 비용 1: 뒤에 } } } } return dist;}시간 복잡도: O(V + E) (다익스트라의 O(E log V)보다 빠르다)왜 동작하는가덱 앞에서 꺼내므로, 거리가 작은 것이 먼저 처리된다.가중치 0 간선은 거리가 같으므로 앞에 넣어 먼저 처리하고,가중치 1 간선은 거리가 +1이므로 뒤에 넣어 나중에 처리한다.→ BFS처럼 거리 순서가 유지된다.flowchart TD A["시작: offerFirst(start)"] --> B["pollFirst() = u"] B --> C["u의 간선 (v, w) 탐색"] C --> D{"dist[u] + w < dist[v]?"} D -->|Yes| E{"w == 0?"} E -->|Yes| F["offerFirst(v)"] E -->|No| G["offerLast(v)"] D -->|No| C F --> B G --> B0-1 BFS가 쓰이는 상황 문제 유형 설명 벽 부수기 벽을 부수면 비용 1, 빈 칸 이동은 비용 0 도로 역방향 도로를 뒤집으면 비용 1, 순방향은 비용 0 격자 이동 특정 방향은 비용 0, 다른 방향은 비용 1 7. 모노톤 덱의 원리왜 모노톤 덱이 정확한 답을 주는가?덱에 남아 있는 원소는 "미래에 최솟값이 될 후보"다.원소 x가 들어올 때 덱 뒤에 x보다 큰 원소가 있으면:- 그 원소는 x보다 먼저 윈도우 밖으로 나가거나- 나가기 전에도 x가 더 작으므로 최솟값이 될 수 없다→ 따라서 제거해도 답에 영향이 없다이것이 Amortized O(1) 인 이유:각 원소는 최대 한 번 들어가고 한 번 나간다→ 총 2N번의 연산 → O(N)8. 다중 조건 슬라이딩 윈도우때로는 최솟값과 최댓값의 차이가 K 이하인 가장 긴 구간을 찾는 문제가 나온다.int longestSubarray(int[] arr, int k) { Deque<Integer> maxDeque = new ArrayDeque<>(); Deque<Integer> minDeque = new ArrayDeque<>(); int left = 0, ans = 0; for (int right = 0; right < arr.length; right++) { while (!maxDeque.isEmpty() && arr[maxDeque.peekLast()] <= arr[right]) maxDeque.pollLast(); while (!minDeque.isEmpty() && arr[minDeque.peekLast()] >= arr[right]) minDeque.pollLast(); maxDeque.offerLast(right); minDeque.offerLast(right); while (arr[maxDeque.peekFirst()] - arr[minDeque.peekFirst()] > k) { left++; if (maxDeque.peekFirst() < left) maxDeque.pollFirst(); if (minDeque.peekFirst() < left) minDeque.pollFirst(); } ans = Math.max(ans, right - left + 1); } return ans;}모노톤 덱 두 개(최대용 + 최소용)를 동시에 유지한다.9. 덱 vs 스택 vs 큐 자료구조 삽입 삭제 코테 용도 Stack 뒤에만 뒤에만 괄호, 히스토그램, DFS Queue 뒤에만 앞에만 BFS Deque 앞뒤 모두 앞뒤 모두 슬라이딩 윈도우, 0-1 BFS Java에서는 세 가지 모두 ArrayDeque로 구현할 수 있다.10. 자주 하는 실수1) 인덱스와 값을 혼동모노톤 덱에는 인덱스를 저장한다. 값이 아니다.값을 저장하면 윈도우 범위 체크가 안 된다.2) 윈도우 범위 체크를 빠뜨림while (!deque.isEmpty() && deque.peekFirst() <= i - k) { deque.pollFirst();}이걸 안 하면 윈도우 밖의 원소가 답에 포함된다.3) 0-1 BFS에서 dist 갱신 조건 실수if (dist[u] + w < dist[v]) // 엄격한 < 여야 한다<=로 하면 같은 거리로 중복 방문하여 무한 루프 가능.4) LinkedList를 덱으로 사용LinkedList는 Deque를 구현하지만 ArrayDeque보다 느리다.코테에서는 항상 ArrayDeque를 쓰자.11. 시험장용 최소 암기 버전Deque 생성:Deque<Integer> dq = new ArrayDeque<>();슬라이딩 윈도우 최솟값 (모노톤 덱):1. 앞에서 윈도우 밖 제거 (deque.peekFirst() <= i - k)2. 뒤에서 현재보다 크거나 같은 것 제거3. 현재 인덱스 offerLast4. deque.peekFirst()가 최솟값0-1 BFS:가중치 0 → offerFirst가중치 1 → offerLast나머지는 BFS와 동일핵심:덱에는 인덱스를 저장각 원소 최대 1번 입출 → O(N)12. 최종 요약덱은 다음 문장으로 정리할 수 있다.양쪽 끝에서 O(1)에 삽입/삭제하는 자료구조코테에서의 활용은 두 가지가 핵심이다.1. 슬라이딩 윈도우 최솟값/최댓값 → 모노톤 덱2. 0-1 BFS → 가중치 0은 앞, 1은 뒤문제를 보면 이 질문을 하면 된다."구간이 이동하면서 최솟값/최댓값을 빠르게 구해야 하는가?"→ 모노톤 덱"간선 가중치가 0과 1뿐인가?"→ 0-1 BFS with 덱
- Bitmask Algorithm algorithm bitmask Bitmask Bitmask비트마스크(Bitmask)는 정수의 비트를 이용해 여러 개의 상태를 압축해서 표현하는 기법이다.한 줄로 요약하면 다음과 같다.여러 개의 on/off 상태를 하나의 정수로 관리한다코딩테스트에서는 “집합”이나 “방문 상태”를 작게 압축하는 용도로 매우 자주 나온다.1. 언제 쓰는가아래 상황이면 비트마스크를 떠올릴 수 있다. 방문한 원소 집합 관리 부분집합 순회 상태 압축 DP 작은 N에 대해 모든 집합 탐색 포함 / 제외 상태를 빠르게 관리특히 다음이 보이면 강하게 의심한다.N <= 20왜냐하면 부분집합 수가 2^N이라,20 정도면 전부 보는 것이 아직 가능하기 때문이다.2. 왜 유용한가예를 들어 방문 여부를 boolean[]로 관리할 수도 있다.하지만 비트마스크를 쓰면: 상태 전체를 정수 하나로 표현 가능 비교 / 복사 / 저장이 쉬움 DP 상태로 쓰기 좋음즉 여러 개의 true/false를 하나로 압축하는 셈이다.3. 기본 연산i번째 비트 켜기mask |= (1 << i);i번째 비트 끄기mask &= ~(1 << i);i번째 비트 토글mask ^= (1 << i);i번째 비트 확인(mask & (1 << i)) != 0이 네 개는 거의 외워야 한다.예: mask = 0101 (5)켜기: mask |= (1<<1) → 0101 | 0010 = 0111 (7) → 1번 비트 ON끄기: mask &= ~(1<<2) → 0111 & 1011 = 0011 (3) → 2번 비트 OFF확인: mask & (1<<0) → 0011 & 0001 = 0001 (≠0) → 0번 포함토글: mask ^= (1<<1) → 0011 ^ 0010 = 0001 (1) → 1번 반전4. 부분집합으로 이해하기원소가 4개라고 하자. 0000 : 공집합 0001 : 0번만 포함 0101 : 0번, 2번 포함 1111 : 전부 포함즉 mask 하나가 집합 하나를 뜻한다.flowchart TD subgraph "mask = 1011 (10진수 11)" B3["Bit 3 = 1<br>C 포함"] B2["Bit 2 = 0<br>B 제외"] B1["Bit 1 = 1<br>A 포함"] B0["Bit 0 = 1<br>D 포함"] end보통 오른쪽 끝 비트를 0번 원소로 본다.즉 정수 하나가 곧 집합 하나를 뜻하고,어떤 비트가 켜져 있느냐가 어떤 원소를 포함하는지를 나타낸다.5. 모든 부분집합 순회원소가 n개면 부분집합은 2^n개다.for (int mask = 0; mask < (1 << n); mask++) { // mask 하나가 부분집합 하나}예를 들어 n = 3이면: 000 001 010 011 100 101 110 111총 8개다.6. 부분집합 안의 원소 순회for (int i = 0; i < n; i++) { if ((mask & (1 << i)) != 0) { // i번째 원소가 포함됨 }}즉 mask 하나를 보고,어떤 원소가 들어 있는지 확인할 수 있다.7. 손으로 보는 예시원소 {A, B, C}가 있다고 하자.mask = 5이면 이진수로 101이다.의미: 0번 비트 켜짐 -> A 포함 1번 비트 꺼짐 -> B 제외 2번 비트 켜짐 -> C 포함즉 집합은 {A, C}다.이 감각이 중요하다.8. 집합 연산도 빠르게 가능하다비트마스크는 집합 연산과 잘 대응된다. 합집합: a | b 교집합: a & b 차집합 일부: a & ~b즉 집합을 정수처럼 다룰 수 있다.그래서 비트마스크를 익히면“집합 연산”과 “비트 연산”이 사실상 같은 것으로 느껴지기 시작한다.9. 상태 압축 DP비트마스크가 강력한 이유는 DP와 결합될 때다.대표 예시:dp[mask][last]의미: mask: 방문한 정점 집합 last: 마지막 정점즉 여러 방문 상태를 boolean[] 대신 정수 하나로 압축해 두는 것이다.대표 문제: 외판원 순회 TSP 방문 집합 기반 최소 비용 문제 매칭 일부 문제예를 들어 외판원 순회에서mask = 13이라면 이진수 1101로 해석할 수 있고,“0, 2, 3번 도시는 방문했고 1번 도시는 아직 안 갔다”는 뜻이 된다.이처럼 boolean[] visited 여러 칸을 정수 하나로 바꿔 저장하는 것이 상태 압축 DP의 핵심이다.graph LR subgraph "TSP: 4개 도시, mask=1101, last=3" C0["도시0 ✓"] C1["도시1 ✗"] C2["도시2 ✓"] C3["도시3 ✓ ← 현재"] end Q["dp[1101][3] = 0,2,3 방문 후<br>도시3에 있을 때의 최소 비용"] Q --> N["다음: dp[1111][1]<br>= 도시1 방문 추가"]전이: dp[mask | (1<<next)][next] = min(dp[mask][last] + dist[last][next])단, next가 mask에 아직 없어야 한다: (mask & (1<<next)) == 010. 왜 N <= 20이 자주 보이는가부분집합 수는 2^N개다. N = 10 -> 1024 N = 20 -> 약 100만 N = 25 -> 약 3300만즉 N이 조금만 커져도 상태 수가 폭발한다.그래서 비트마스크는 보통 작은 N에서 강하다.즉 비트마스크는 강력하지만,상태 수가 2^N이라서 입력 크기에 매우 민감하다는 점도 같이 기억해야 한다.11. 부분집합 DP가 아니라도 자주 쓰인다비트마스크는 DP에서만 쓰는 것이 아니다.예: 알파벳 방문 여부 퍼즐 상태 저장 여러 조건 on/off 관리 부분집합 완전탐색즉 “상태가 여러 개의 예/아니오 조합으로 표현될 수 있는가”를 먼저 보면 된다.대표적으로 문자열 문제에서 알파벳 사용 여부,그래프 문제에서 방문한 정점 집합,브루트포스 문제에서 선택한 원소 집합을 표현할 때 매우 자주 등장한다.부분집합의 부분집합을 도는 패턴도 중요하다실전에서는 어떤 집합 mask의 모든 부분집합 sub를 다시 순회하는 경우가 자주 나온다.for (int sub = mask; sub > 0; sub = (sub - 1) & mask) { // sub는 mask의 부분집합}이 패턴이 맞는 이유는sub - 1이 마지막 켜진 비트를 끄고 오른쪽을 전부 1로 만든 뒤,& mask가 원래 집합 밖의 비트를 정리해 주기 때문이다.예를 들어 mask = 1101이면 순회는:1101 -> 1100 -> 1001 -> 1000 -> 0101 -> 0100 -> 0001처럼 진행되고,공집합 0000은 필요하면 루프 밖에서 따로 처리한다.이 패턴은 부분집합 DP, SOS DP, 브루트포스 최적화에서 매우 자주 쓰인다.원소 수는 bitCount로 바로 셀 수 있다int cnt = Integer.bitCount(mask);선택된 원소 수, 홀짝 판단, k개 선택 제약 같은 문제에서는직접 루프를 돌기보다 bitCount를 쓰는 편이 더 간단하고 실수도 적다.12. 자주 하는 실수1) 비트 인덱스를 0부터 세는지 혼동대부분 0번 비트부터 쓴다.2) 1 << n 범위 초과n이 너무 크면 int가 위험할 수 있다.필요하면 1L << n을 써야 한다.3) 원소 번호와 비트 번호를 그대로 섞음문제 원소 번호가 1부터 시작하면,비트는 보통 0부터 맞춰 주는 것이 편하다.4) 음수 시프트나 괄호 실수mask & (1 << i)처럼 괄호를 명확히 두는 편이 안전하다.13. 시험장용 최소 암기 버전비트마스크:여러 boolean 상태를 정수 하나로 압축기본 연산:켜기 |=끄기 &= ~토글 ^=확인 &핵심 사용처:부분집합 순회방문 상태 압축DP 상태 압축14. 최종 요약비트마스크는 다음 문장으로 정리할 수 있다.여러 개의 on/off 상태를 비트 하나씩에 담아빠르게 집합을 관리하는 기법문제를 보면 먼저 이 질문을 하면 된다.이 상태를 boolean 여러 개 대신정수 하나로 압축할 수 있는가?그렇다면 비트마스크를 고려할 만하다.
- Binary Search Algorithm algorithm binary-search Binary Search Binary Search이분 탐색(Binary Search)은 정렬된 구간이나 단조적인 조건 위에서 정답을 절반씩 줄여 가며 찾는 알고리즘이다.한 줄로 요약하면 다음과 같다.정답 후보 구간을 매번 절반으로 줄이는 탐색 기법1. 언제 쓰는가문제에서 아래 표현이 보이면 이분 탐색을 떠올리면 된다. 정렬된 배열에서 값 찾기 특정 값 이상이 처음 나오는 위치 특정 값 이하가 마지막으로 나오는 위치 가능한 정답 범위가 크다 조건을 만족하는 최소값 / 최대값 어떤 기준으로 가능 / 불가능이 단조롭게 갈린다2. 핵심 아이디어이분 탐색의 핵심은 다음과 같다.mid를 기준으로 왼쪽 또는 오른쪽 절반만 남긴다즉 전체를 다 보지 않고,조건을 이용해 절반을 버린다.3. 가장 중요한 전제: 단조성이분 탐색은 아무 문제에나 되는 것이 아니다.핵심 전제는 단조성이다.예: 정렬된 배열에서 arr[mid] < target이면 왼쪽은 전부 버릴 수 있다 어떤 값 x가 가능하면 그보다 작은 값도 전부 가능하다 어떤 값 x가 불가능하면 그보다 큰 값도 전부 불가능하다즉 경계가 하나로 나뉘어 있어야 한다.flowchart LR A["불가능"] --> B["불가능"] B --> C["불가능"] C --> D["가능"] D --> E["가능"] E --> F["가능"]이처럼 불가능 -> 가능 또는 가능 -> 불가능이 한 번만 바뀌면 이분 탐색이 가능하다.4. 정렬된 배열에서 값 찾기가장 기본적인 형태다.int binarySearch(int[] arr, int target) { int left = 0; int right = arr.length - 1; while (left <= right) { int mid = left + (right - left) / 2; if (arr[mid] == target) return mid; if (arr[mid] < target) left = mid + 1; else right = mid - 1; } return -1;}핵심: left <= right mid 계산 비교 후 구간 반으로 축소5. Lower Bound와 Upper Bound코테에서는 단순 탐색보다 경계 찾기가 더 자주 나온다.Lower Boundtarget 이상이 처음 나오는 위치Upper Boundtarget 초과가 처음 나오는 위치이 두 개를 알면 다음이 가능하다. 특정 값의 개수 구하기 중복 원소 범위 찾기 삽입 위치 찾기6. Lower Boundint lowerBound(int[] arr, int target) { int left = 0; int right = arr.length; while (left < right) { int mid = left + (right - left) / 2; if (arr[mid] >= target) right = mid; else left = mid + 1; } return left;}구간을 [left, right)로 잡는 형태다.7. Upper Boundint upperBound(int[] arr, int target) { int left = 0; int right = arr.length; while (left < right) { int mid = left + (right - left) / 2; if (arr[mid] > target) right = mid; else left = mid + 1; } return left;}특정 값 x의 개수는:upperBound(arr, x) - lowerBound(arr, x)이다.작은 예시로 보면 더 직관적이다.배열:[1, 2, 2, 2, 5, 7]에서 lowerBound(2)는 인덱스 1 upperBound(2)는 인덱스 4가 된다.즉 값 2의 개수는:4 - 1 = 3이다.이 감각이 익숙해지면 lower bound와 upper bound는“경계 위치를 찾는 함수”로 자연스럽게 이해된다.경계 불변식으로 이해하면 덜 틀린다lowerBound를 [left, right) 형태로 구현할 때는항상 다음 불변식을 유지한다고 생각하면 좋다. 0 .. left - 1 구간은 이미 target보다 작다고 확정 right .. n - 1 구간은 이미 target 이상이라고 확정 따라서 실제 정답 후보는 항상 [left, right) 안에만 남아 있다flowchart LR A["확정 영역 < target"] --> B["후보 구간 [left, right)"] B --> C["확정 영역 >= target"]이 관점에서 보면: arr[mid] >= target이면 mid도 정답 후보이므로 right = mid arr[mid] < target이면 mid는 탈락이므로 left = mid + 1가 자연스럽게 나온다.즉 return left가 맞는 이유도“후보 구간이 한 칸으로 줄어든 지점”이기 때문이라고 이해하면 된다.8. 정답을 찾는 이분 탐색 Parametric Search이분 탐색은 배열에서 값 찾기에만 쓰지 않는다.오히려 코테에서는 정답 자체를 이분 탐색하는 경우가 매우 많다.문제 예시: 랜선 자르기 나무 자르기 가능한 최소 시간 가능한 최대 거리핵심은 다음이다.x가 정답 후보일 때 가능 / 불가능을 판별할 수 있는가?가능하다면, 그 조건이 단조적인지만 보면 된다.이때 중요한 것은:정답 후보 하나를 잡았을 때그 값이 가능한지 판별하는 함수 can(x)를 만들 수 있는가이다.배열에서 값을 찾는 이분 탐색은 arr[mid]와 비교하지만,파라메트릭 서치는 can(mid)의 참/거짓으로 방향을 정한다.9. Parametric Search 사고법예를 들어 “높이를 h로 잘랐을 때 필요한 나무를 충분히 얻을 수 있는가”를 판별할 수 있다고 하자.그러면: h가 낮으면 많이 얻는다 -> 가능 h가 높으면 적게 얻는다 -> 불가능즉 가능 -> 불가능 경계가 하나 생긴다.이 경계를 이분 탐색으로 찾는 것이다.손으로 따라가 보자.나무 높이가 [20, 15, 10, 17]이고,필요한 길이가 7이라고 하자. h = 15면 얻는 길이 = 5 + 0 + 0 + 2 = 7 -> 가능 h = 16이면 얻는 길이 = 4 + 0 + 0 + 1 = 5 -> 불가능즉 정답은 15라는 것을 경계 탐색으로 찾을 수 있다.10. Parametric Searchlong binarySearchAnswer(long left, long right) { long answer = -1; while (left <= right) { long mid = left + (right - left) / 2; if (can(mid)) { answer = mid; left = mid + 1; // 더 큰 쪽도 가능한지 확인 } else { right = mid - 1; } } return answer;}이 방식은 “가능한 최대값”을 찾을 때 자주 쓴다.“가능한 최소값”을 찾고 싶으면 갱신 방향이 반대가 된다.11. 최소값 / 최대값 찾기 구분가능한 최대값 찾기 can(mid) == true면 더 큰 쪽을 본다 left = mid + 1가능한 최소값 찾기 can(mid) == true면 더 작은 쪽도 가능한지 본다 right = mid - 1즉,정답을 키우고 싶은가, 줄이고 싶은가를 먼저 정해야 한다.실전에서는 이것만 명확하면 구현이 크게 단순해진다. 가능한 최대값을 찾는가? 가능한 최소값을 찾는가?이 둘을 먼저 종이에 적어 두면 left, right 갱신을 덜 틀린다.12. 왜 mid = left + (right - left) / 2 를 쓰는가단순히 (left + right) / 2도 되지만,정수 범위가 크면 overflow 가능성이 있다.그래서 안전하게:int mid = left + (right - left) / 2;를 쓰는 습관이 좋다.추가로 이분 탐색은 구간 표현도 통일해야 덜 틀린다. 값 찾기 기본형: 보통 [left, right] lower/upper bound: 보통 [left, right)이 두 스타일을 섞으면 off-by-one 실수가 매우 자주 난다.13. 자주 하는 실수 정렬 안 된 배열에 이분 탐색 사용 단조성이 없는 문제를 억지로 이분 탐색 left, right, mid 업데이트 실수 <= 와 < 조건 혼동 lower bound / upper bound 경계 헷갈림 정답 저장 위치를 잘못 둠14. 시험장용 최소 암기 버전이분 탐색:단조성 + 절반씩 줄이기기본:정렬된 배열에서 값 찾기확장:정답 자체를 이분 탐색핵심 질문:can(mid)를 만들 수 있는가?조건이 한 번만 바뀌는가?15. 최종 요약이분 탐색은 다음 문장으로 정리할 수 있다.정답 후보 구간을 절반씩 줄이며단조적인 경계를 찾는 탐색 기법
- Bellman-Ford Algorithm algorithm bellman-ford Bellman-Ford Bellman-FordBellman-Ford는 음수 간선이 있을 수 있는 그래프에서 한 시작점 최단 거리를 구하는 알고리즘이다.한 줄로 요약하면 다음과 같다.모든 간선을 여러 번 완화하면서 최단 거리를 갱신하는 알고리즘다익스트라와 비교하면 느리지만,음수 간선과 음수 사이클 판별을 다룰 수 있다는 점이 강점이다.1. 언제 쓰는가아래 상황이면 Bellman-Ford를 고려한다. 음수 간선이 존재할 수 있음 한 시작점 최단 거리 문제 음수 사이클 여부까지 판단해야 함 다익스트라를 쓰면 안 되는 입력즉 다음 분기가 핵심이다. 음수 간선 없음 -> Dijkstra 우선 음수 간선 있음 -> Bellman-Ford 검토2. 핵심 아이디어최단 경로가 잘 정의되어 있다면 같은 정점을 반복하지 않는다고 볼 수 있으므로,간선을 최대 V - 1개만 사용한다.따라서:모든 간선을 V - 1번 반복해서 완화하면최단 거리가 전파된다여기서 완화(relaxation)란,더 짧은 경로를 발견했을 때 거리값을 갱신하는 것이다.flowchart TD A["V-1번 반복"] --> B["모든 간선 검사"] B --> C{"더 짧은 경로 발견"} C -->|Yes| D["거리 갱신"] C -->|No| E["현재 값 유지"] D --> B E --> B즉 Bellman-Ford는 “가장 가까운 정점 하나를 확정”하는 방식이 아니라,모든 간선을 반복해서 훑으며 최단 거리 정보가 한 단계씩 퍼져 나가게 만드는 알고리즘이다.3. 완화가 무슨 뜻인가간선 (u, v, w)가 있다고 하자.현재: dist[u] = 5 dist[v] = 20 w = 3이면 u를 거쳐 v로 가는 비용은 8이다.기존 20보다 짧으므로:dist[v] = 8;로 갱신한다.즉 완화는:더 짧은 길이 있으면 거리표를 줄이는 작업이다.4. 왜 V - 1번이면 충분한가정점이 V개인 그래프에서,도달 가능한 음수 사이클이 없다면,사이클을 사용하지 않는 최단 경로는 최대 V - 1개의 간선만 가진다.예: 정점 5개 한 경로가 모든 정점을 한 번씩 지난다고 해도 간선은 최대 4개즉 1번 완화로 길이 1 경로가,2번 완화로 길이 2 경로가,…V - 1번 완화로 길이 V - 1 경로까지 전파된다.5. 작은 예시로 따라가기그래프:1 -> 2 (4)1 -> 3 (5)2 -> 3 (-2)3 -> 4 (3)시작점은 1이다.초기 거리:dist[1] = 0dist[2] = INFdist[3] = INFdist[4] = INF1번째 완화 라운드 1 -> 2: dist[2] = 4 1 -> 3: dist[3] = 5 2 -> 3: dist[3] = min(5, 4 + -2) = 2 3 -> 4: dist[4] = 5결과:0, 4, 2, 52번째 완화 라운드더 줄어드는 값이 있는지 본다.이번에는 이미 최단 거리가 모두 전파되어 더 이상 갱신이 없다.즉 Bellman-Ford는 이렇게 간선을 반복해서 보면서,최단 경로 정보가 멀리 퍼지게 만든다.6. 전체 구현import java.util.*;class Edge { int from; int to; int cost; Edge(int from, int to, int cost) { this.from = from; this.to = to; this.cost = cost; }}long[] bellmanFord(int n, List<Edge> edges, int start) { long INF = 1_000_000_000_000L; long[] dist = new long[n + 1]; Arrays.fill(dist, INF); dist[start] = 0; for (int i = 1; i <= n - 1; i++) { boolean updated = false; for (Edge e : edges) { if (dist[e.from] == INF) continue; if (dist[e.to] > dist[e.from] + e.cost) { dist[e.to] = dist[e.from] + e.cost; updated = true; } } if (!updated) break; } return dist;}여기서 updated 최적화는 중요하다.어떤 라운드에서 더 이상 갱신이 없다면,그 뒤로도 변하지 않으므로 일찍 종료할 수 있다.7. 음수 사이클 판별V - 1번 완화가 끝난 뒤에도,한 번 더 완화를 했을 때 값이 줄어든다면 음수 사이클이 있다.왜냐하면 정상적인 최단 경로는 이미 다 전파됐어야 하는데,계속 줄어든다는 것은 음수 사이클을 돌면서 값을 무한히 낮출 수 있다는 뜻이기 때문이다.boolean hasNegativeCycle(List<Edge> edges, long[] dist, long INF) { for (Edge e : edges) { if (dist[e.from] == INF) continue; if (dist[e.to] > dist[e.from] + e.cost) { return true; } } return false;}여기서 핵심은 "한 번 더"라는 점이다. V - 1번까지는 정상 최단 경로 전파 그 다음 한 번은 음수 사이클 존재 여부 확인즉 마지막 1회는 최단 거리 계산용이 아니라,그래프의 모순 여부를 검사하는 단계라고 보면 된다.위 함수의 INF는 앞선 bellmanFord와 같은 상수를 넘겨준다고 생각하면 된다.8. 주의: 시작점에서 도달 가능한 음수 사이클만 잡힌다Bellman-Ford에서 dist[e.from] == INF를 건너뛰므로,시작점에서 갈 수 없는 음수 사이클은 현재 시작점 기준 최단 거리 계산에는 영향을 주지 않는다.즉 문제에서 묻는 것이: 시작점 기준 최단 거리인가 그래프 전체의 어떤 음수 사이클이든 존재하는가에 따라 해석이 달라질 수 있다.작은 예시를 보자. 시작점이 1 1과 전혀 연결되지 않은 다른 컴포넌트에 음수 사이클 존재이 경우 Bellman-Ford는 그 사이클을 현재 시작점 기준 계산에서는 감지하지 못할 수 있다.즉 "그래프 전체 어디든 음수 사이클 있나"를 묻는 문제와"시작점에서 영향을 받는 음수 사이클 있나"를 묻는 문제는 다를 수 있다.이 차이를 문제에서 반드시 확인해야 한다.그래프 전체 음수 사이클 검사는 super source를 붙이면 된다만약 문제에서 “그래프 전체 어딘가에 음수 사이클이 있는가”를 묻는다면,가상의 정점 0을 만들고 모든 정점으로 비용 0 간선을 연결하면 된다.flowchart TD S["가상 시작점 0"] --> A["1"] S --> B["2"] S --> C["3"] S --> D["..."]그다음 0에서 Bellman-Ford를 시작하면모든 컴포넌트가 도달 가능한 상태가 된다.구현 감각은 다음과 같다.for (int v = 1; v <= n; v++) { edges.add(new Edge(0, v, 0));}// 정점 번호 범위가 0..n 이므로 총 정점 수는 n + 1long[] dist = bellmanFord(n + 1, edges, 0);즉 시작점 하나의 문제를“전체 그래프를 다 보는 시작점” 문제로 바꿔 주는 셈이다.9. 음수 사이클 예시를 손으로 보기그래프:1 -> 2 (1)2 -> 3 (-2)3 -> 2 (-2)시작점은 1이라고 하자.2와 3 사이를 한 번 돌면 비용이:-2 + -2 = -4이다.즉 2와 3을 계속 왕복할수록 거리값을 더 줄일 수 있다.이 경우 V - 1번 완화 후에도2, 3의 거리는 한 번 더 줄어들 수 있다.그래서 Bellman-Ford는 마지막 1회 검사에서 이를 음수 사이클로 판별한다.음수 사이클 다이어그램graph LR 1 -->|"1"| 2 2 -->|"-2"| 3 3 -->|"-2"| 22 → 3 → 2 경로의 비용 합이 $-2 + (-2) = -4 < 0$ 이므로,이 사이클을 반복할수록 거리가 무한히 줄어든다.10. 왜 간선 리스트로 구현하는가Bellman-Ford는 "모든 간선을 매 라운드마다 한 번씩 본다"가 핵심이다.그래서 인접 리스트보다는 오히려:List<Edge> edges형태가 더 자연스럽다.다익스트라는 현재 정점에서 나가는 간선만 보면 되므로 인접 리스트가 잘 맞고,Bellman-Ford는 그래프 전체 간선을 반복 스캔하므로 간선 리스트가 잘 맞는다.11. 시간 복잡도Bellman-Ford의 시간 복잡도는:O(VE)이다.그래서 정점과 간선이 큰 그래프에서는 느리다.음수 간선이 없다면 보통 Dijkstra가 훨씬 효율적이다.12. 언제 Bellman-Ford를 실제로 고르는가실전에서는 다음 식으로 판단하면 된다. 음수 간선이 없다 -> Dijkstra 먼저 음수 간선이 있다 -> Bellman-Ford 검토 모든 쌍이 필요하고 정점 수가 작다 -> Floyd-Warshall 검토즉 Bellman-Ford는 가장 빠른 기본 선택은 아니지만,음수 간선 때문에 다른 선택지가 막힐 때 매우 중요해진다.13. Dijkstra와 비교 항목 Bellman-Ford Dijkstra 음수 간선 가능 불가 음수 사이클 판별 가능 불가 속도 느림 빠름 핵심 구조 모든 간선 반복 완화 우선순위 큐 그리디 즉 Bellman-Ford는 “느리지만 더 일반적”인 알고리즘이다.14. Floyd-Warshall과 비교 Bellman-Ford: 시작점 하나 Floyd-Warshall: 모든 정점 쌍즉 둘 다 음수 간선을 다룰 수 있지만,문제가 요구하는 시작점 범위가 다르다.15. 자주 하는 실수1) INF 상태에서 더함도달하지 못한 정점은 건너뛰어야 한다.2) V번이 아니라 V - 1번 완화해야 함최단 경로 간선 수 상한은 V - 1개다.3) 음수 사이클 판별 단계를 빼먹음문제가 음수 사이클 존재 여부를 물으면 반드시 한 번 더 확인해야 한다.4) int overflow거리 합이 커질 수 있으므로 long이 더 안전하다.16. 시험장용 최소 암기 버전Bellman-Ford:음수 간선 가능한 단일 시작점 최단 거리핵심:모든 간선을 V-1번 완화음수 사이클:한 번 더 완화했는데 갱신되면 존재복잡도:O(VE)17. 최종 요약Bellman-Ford는 다음 문장으로 정리할 수 있다.음수 간선을 허용하는 그래프에서,모든 간선을 반복 완화해 한 시작점 최단 거리를 구하는 알고리즘문제를 보면 먼저 이 질문을 하면 된다.음수 간선이 있을 수 있는가?그 답이 예라면 Bellman-Ford를 검토해야 한다.
- Backtracking Algorithm algorithm backtracking Backtracking Backtracking백트래킹(Backtracking)은 가능한 선택을 하나씩 시도해 보다가, 더 볼 필요가 없으면 되돌아오는 완전탐색 기법이다.한 줄로 요약하면 다음과 같다.선택하고 내려가고막히면 되돌아온다백트래킹은 DFS와 매우 가깝지만,단순 DFS보다 더 중요한 것이 있다.정답이 될 수 없는 가지를 가능한 한 빨리 자른다이 점이 핵심이다.1. 언제 쓰는가문제에서 아래 표현이 보이면 백트래킹을 떠올리면 된다. 모든 경우를 만들어야 함 순열 / 조합 / 부분집합 조건을 만족하는 경우만 찾기 N-Queen, 스도쿠, 연산자 끼워넣기 경우를 세되 가지치기가 가능함즉 완전탐색이 필요한데,무식하게 다 보면 너무 크고,중간에 버릴 수 있는 조건이 있을 때 백트래킹이 강하다.2. DFS와의 차이 DFS: 그냥 깊이 우선으로 탐색 백트래킹: 불가능한 가지는 더 내려가지 않음즉 백트래킹은 DFS 위에 유효성 검사 + 복구가 추가된 형태라고 보면 된다.flowchart TD A["선택"] --> B["더 깊이 탐색"] B --> C{"유효한가"} C -->|Yes| D["다음 선택지 시도"] C -->|No| E["Backtrack"] D --> B즉 백트래킹의 핵심은 “끝까지 갔다가 돌아온다”가 아니라,조건이 틀린 순간 더 내려가지 않고 곧바로 이전 상태로 복구한다는 데 있다.3. 핵심 구조백트래킹은 거의 항상 아래 구조를 가진다.void dfs(int depth) { if (종료 조건) { 정답 처리; return; } for (선택지 : 가능한 선택들) { if (불가능하면) continue; 선택; dfs(depth + 1); 복구; }}여기서 핵심 3단계는 항상 같다. 선택 재귀 복구복구를 빼먹으면 상태가 다음 가지에 남아서 망가진다.4. 상태 공간 트리로 이해하기백트래킹은 보통 상태 공간 트리로 생각하면 이해가 쉽다.예를 들어 원소 {1, 2, 3}의 부분집합을 만들면: 1을 선택할지 말지 2를 선택할지 말지 3을 선택할지 말지로 계속 분기된다.flowchart TD R["시작"] --> A1["1 포함"] R --> A2["1 제외"] A1 --> B1["2 포함"] A1 --> B2["2 제외"] A2 --> B3["2 포함"] A2 --> B4["2 제외"] B1 --> C1["{1,2,3}"] B1 --> C2["{1,2}"] B2 --> C3["{1,3}"] B2 --> C4["{1}"] B3 --> C5["{2,3}"] B3 --> C6["{2}"] B4 --> C7["{3}"] B4 --> C8["공집합"]즉 각 재귀 호출은 트리의 한 노드이고,선택 하나가 간선 하나라고 보면 된다.5. 순열, 조합, 부분집합의 차이백트래킹 입문에서 가장 중요한 구분이다. 유형 순서 중요 중복 방문 방지 순열 중요 visited 필요 조합 중요하지 않음 start 필요 부분집합 포함/제외 depth만 증가 이 세 가지는 코테에서 거의 공식처럼 나온다.6. 순열의미서로 다른 원소를 순서 있게 뽑는다.예:1 2 와 2 1 은 다르다int[] arr = {1, 2, 3};int[] out = new int[3];boolean[] visited = new boolean[3];void perm(int depth, int r) { if (depth == r) { System.out.println(Arrays.toString(out)); return; } for (int i = 0; i < arr.length; i++) { if (visited[i]) continue; visited[i] = true; out[depth] = arr[i]; perm(depth + 1, r); visited[i] = false; }}visited가 핵심이다.같은 원소를 한 번만 쓰게 만든다.7. 조합의미순서 없이 뽑는다.예:1 2 와 2 1 은 같다int[] arr = {1, 2, 3, 4};int[] out = new int[2];void comb(int depth, int start, int r) { if (depth == r) { System.out.println(Arrays.toString(out)); return; } for (int i = start; i < arr.length; i++) { out[depth] = arr[i]; comb(depth + 1, i + 1, r); }}여기서는 start가 핵심이다.이전 인덱스보다 뒤만 보게 해서 순서 중복을 막는다.8. 부분집합의미각 원소마다: 넣는다 안 넣는다두 갈래로 나뉜다.int[] arr = {1, 2, 3};boolean[] selected = new boolean[arr.length];void subset(int depth) { if (depth == arr.length) { List<Integer> result = new ArrayList<>(); for (int i = 0; i < arr.length; i++) { if (selected[i]) result.add(arr[i]); } System.out.println(result); return; } selected[depth] = false; subset(depth + 1); selected[depth] = true; subset(depth + 1);}부분집합은 백트래킹의 가장 기본적인 이진 분기 구조다.9. 가지치기가 왜 중요한가완전탐색은 경우의 수가 급격히 커진다.예: 순열: N! 부분집합: 2^N N-Queen: 매우 빠르게 폭증따라서 백트래킹의 핵심 질문은 이것이다.이 상태에서 더 내려가도 정답이 나올 가능성이 있는가?없으면 바로 중단해야 한다.10. 대표 예시: N-QueenN-Queen은 백트래킹의 대표 문제다.한 행에 퀸을 하나씩 놓으면서,기존 퀸과 다음 조건이 겹치면 안 된다. 같은 열 같은 대각선즉 현재 행에서 열 하나를 고르되,안전하지 않은 위치는 바로 버린다.핵심 가지치기이미 충돌하면 그 아래 행은 볼 필요가 없다이게 백트래킹의 본질이다.11. N-Queen 코드 감각보통 다음 세 배열을 쓴다.boolean[] col;boolean[] diag1;boolean[] diag2; col[c]: c열 사용 여부 diag1[row + col]: / 방향 대각선 (왼쪽 아래 ↔ 오른쪽 위) diag2[row - col + n]: \ 방향 대각선 (왼쪽 위 ↔ 오른쪽 아래)이렇게 하면 매번 보드를 다 훑지 않고,현재 위치가 가능한지 O(1)에 검사할 수 있다.N-Queen Java 코드int n;boolean[] col;boolean[] diag1; // row + colboolean[] diag2; // row - col + nint count = 0;void solve(int row) { if (row == n) { count++; return; } for (int c = 0; c < n; c++) { if (col[c] || diag1[row + c] || diag2[row - c + n]) continue; col[c] = true; diag1[row + c] = true; diag2[row - c + n] = true; solve(row + 1); col[c] = false; diag1[row + c] = false; diag2[row - c + n] = false; }}핵심 흐름은 다음과 같다. 한 행에 퀸을 하나 놓는다 열, / 대각선, \ 대각선에 충돌이 있으면 건너뛴다 선택 → 재귀 → 복구 순서를 지킨다 모든 행을 채우면 정답 하나를 찾은 것이다12. 복구가 왜 필요한가예를 들어 현재 원소를 선택하고 내려갔다가 돌아오면,다음 가지를 위해 상태를 원래대로 되돌려야 한다.예:visited[i] = true;dfs(depth + 1);visited[i] = false;이 마지막 복구가 없으면,다른 가지에서도 i가 이미 사용된 것처럼 남아 버린다.즉 백트래킹은 상태를 공유하므로,복구가 필수다.13. 중복이 있는 입력에서 주의할 점예를 들어 [1, 1, 2]로 순열을 만들면 중복 결과가 생길 수 있다.이 경우는 보통 다음 방법을 쓴다. 먼저 정렬 같은 깊이에서 같은 값을 여러 번 선택하지 않도록 스킵즉 입력에 중복이 있으면 단순 백트래킹만으로는 부족할 수 있다.14. 자주 하는 실수1) 선택 후 복구를 안 함가장 흔한 실수다.2) 종료 조건을 잘못 둠순열, 조합, 부분집합마다 종료 깊이가 다르다.3) 조합인데 start를 안 씀순서 중복이 생긴다.4) 순열인데 visited를 안 씀같은 원소를 여러 번 사용하게 된다.5) 가지치기 조건이 없음시간 초과가 나기 쉽다.15. 시험장용 최소 암기 버전백트래킹:선택 -> 재귀 -> 복구핵심:불가능한 가지는 빨리 자른다대표:순열조합부분집합N-Queen포인트:visitedstartpruning16. 최종 요약백트래킹은 다음 문장으로 정리할 수 있다.가능한 선택을 만들어 보되,정답이 될 수 없는 가지는 즉시 잘라내는 DFS 기반 완전탐색문제를 보면 먼저 이 질문을 하면 된다.모든 경우를 만들되,중간에 버릴 수 있는 조건이 있는가?있다면 백트래킹일 가능성이 높다.
- CS - OS CS cs os CS 단권화 문서입니다.(updated on 2026-03-06) CS 단권화 문서입니다.(updated on 2026-03-06)1. OS OverviewKernelflowchart TD A[사용자 프로그램] --> B[System Call 인터페이스] B --> C["Kernel Space(커널 공간)"] C --> C1["Scheduler(스케줄러)"] C --> C2[메모리 관리자] C --> C3[파일 시스템] C --> C4["Network Stack(네트워크 스택)"] C --> C5["Device Drivers(디바이스 드라이버)"] C5 --> D["Hardware(하드웨어)"]운영체제의 커널(Kernel)은 하드웨어와 소프트웨어 사이에서 자원 관리의 중심 역할을 수행하는 핵심 계층이다. CPU 스케줄링, 메모리 할당, 파일 시스템, 네트워크 스택, 디바이스 제어 같은 기능은 대부분 커널 내부에서 동작한다. 사용자 프로그램은 직접 하드웨어를 제어하지 않고 커널이 제공하는 인터페이스를 통해 간접적으로 접근한다.커널 설계 방식은 크게 모놀리식 커널(기능을 하나의 커널 공간에 통합), 마이크로커널(핵심 최소 기능만 커널에 두고 나머지는 사용자 공간 서비스로 분리), 하이브리드 커널(둘의 절충)로 나뉜다. Linux는 모놀리식 기반에 모듈화를 적극 도입한 형태이고, Windows NT 계열은 하이브리드 성격이 강하다.모놀리식 커널은 파일시스템, 네트워크 드라이버, 프로세스 관리 등 모든 서비스가 하나의 주소 공간에서 실행되므로, 서비스 간 함수 호출이 직접적이라 오버헤드가 낮다. 반면 드라이버 버그 하나가 커널 전체를 크래시시킬 수 있는 위험이 있다. Linux는 이 문제를 완화하기 위해 LKM(Loadable Kernel Module) 방식으로 모듈을 동적으로 적재/해제할 수 있게 했다. insmod, rmmod, modprobe 명령으로 런타임에 기능을 추가/제거할 수 있어 모놀리식의 성능 이점을 유지하면서 유연성을 확보한다.마이크로커널(Mach, MINIX, QNX 등)은 IPC(프로세스 간 통신), 기본 스케줄링, 메모리 관리만 커널에 두고, 파일 시스템과 디바이스 드라이버를 사용자 공간 서버로 분리한다. 격리성이 뛰어나 하나의 서비스 장애가 전체 시스템을 망가뜨리지 않지만, 서비스 간 IPC(메시지 패싱) 오버헤드가 성능 병목이 된다. 실시간 시스템(QNX)이나 고신뢰 환경에서 주로 선택된다.하이브리드 커널은 두 접근법을 절충한다. Windows NT는 커널 모드에 Executive(메모리 관리자, I/O 관리자, 객체 관리자 등)를 포함시키면서도 서브시스템(Win32, POSIX)은 사용자 공간에서 실행한다. macOS의 XNU도 Mach 마이크로커널 위에 BSD 계층을 커널 공간에 통합한 하이브리드 구조다.User Mode vs Kernel ModesequenceDiagram participant U as User Mode(유저 모드) participant CPU as CPU participant K as Kernel Mode(커널 모드) U->>CPU: syscall / trap CPU->>K: privilege switch (Ring3 -> Ring0) K->>K: validate args + execute privileged op K-->>CPU: 결과 반환 CPU-->>U: 모드 복귀 (Ring0 -> Ring3)CPU는 보호 모드에서 권한 수준을 구분한다. 일반 애플리케이션은 User Mode(비특권 모드)에서 실행되고, 커널 코드는 Kernel Mode(특권 모드)에서 실행된다. User Mode에서는 I/O 포트 접근, 페이지 테이블 직접 수정, 인터럽트 제어와 같은 민감 연산이 금지된다.이 분리는 안정성과 보안을 위한 핵심 장치다. 사용자 코드 버그가 전체 시스템을 즉시 망가뜨리지 않도록 격리하고, 커널이 모든 민감 자원 접근을 중재한다. 권한 전환은 시스템 콜 또는 인터럽트/예외 트랩을 통해 발생한다.x86 아키텍처에서는 Ring 0(커널)부터 Ring 3(사용자)까지 4단계 보호 링을 제공하지만, 대부분의 OS는 Ring 0(Kernel Mode)과 Ring 3(User Mode)만 사용한다. ARM에서는 EL0(유저)/EL1(커널)/EL2(하이퍼바이저)/EL3(보안 모니터)로 구분된다.모드 전환 비용은 무시할 수 없다. User → Kernel 전환 시 CPU 레지스터 저장, 커널 스택 설정, TLB 플러시(KPTI 환경), 보안 검증이 수행된다. Spectre/Meltdown 대응으로 KPTI(Kernel Page Table Isolation)가 도입되면서 커널/사용자 페이지 테이블을 분리해 전환 비용이 더 증가했다. 이는 시스템 콜 집약적 워크로드에서 체감 성능 저하로 이어질 수 있다.vDSO(virtual Dynamic Shared Object)는 이런 전환 비용을 줄이기 위한 최적화다. gettimeofday 같은 읽기 전용 커널 데이터 접근은 실제 모드 전환 없이 사용자 공간에 매핑된 커널 데이터를 직접 읽어 성능을 높인다.System CallsequenceDiagram participant U as User App(사용자 앱) participant L as libc participant K as Kernel participant D as Device/FS(디바이스/파일시스템) U->>L: write(fd, buf, n) L->>K: syscall(number, args) K->>D: validate + execute I/O D-->>K: 결과 반환 K-->>L: 반환 코드 L-->>U: 기록한 바이트 수 / errno시스템 콜은 사용자 프로그램이 커널 기능을 요청하는 표준 진입점이다. 파일 열기(open), 읽기/쓰기(read, write), 프로세스 생성(fork), 소켓 통신(socket) 등이 대표적이다. 시스템 콜 호출 시 사용자 공간에서 커널 공간으로 컨텍스트가 전환되며, 이 전환 비용은 성능 최적화에서 중요한 고려사항이다.시스템 콜의 내부 동작 과정: 사용자 프로그램이 libc 래퍼 함수를 호출한다 (예: write()) 래퍼는 시스템 콜 번호를 레지스터에 설정하고 syscall 명령(x86-64) 또는 svc 명령(ARM)을 실행한다 CPU가 Kernel Mode로 전환하고, 시스템 콜 테이블에서 해당 번호의 핸들러를 찾아 실행한다 핸들러가 작업을 완료하면 결과를 레지스터에 담고 sysret/eret으로 User Mode에 복귀한다Linux에서 시스템 콜 번호는 아키텍처별로 고정되어 있다. x86-64에서 write는 1번, open은 2번, fork는 57번이다. strace 도구를 사용하면 프로세스가 호출하는 모든 시스템 콜을 추적할 수 있어, 성능 병목이나 에러 원인 분석에 강력하다.// 개념 예시: 파일을 열고 읽는 전형적인 시스템 콜 흐름int fd = open("data.txt", O_RDONLY); // sys_open → 커널: 파일 탐색, inode 로드, fd 할당ssize_t n = read(fd, buf, sizeof(buf)); // sys_read → 커널: 페이지 캐시 확인, 디스크 I/O, 버퍼 복사close(fd); // sys_close → 커널: fd 테이블 정리, inode 참조 감소시스템 콜 최적화 기법으로는 io_uring(Linux 5.1+)이 주목받는다. 커널과 사용자 공간이 공유하는 링 버퍼를 통해 배치 I/O를 수행하므로, 시스템 콜 왕복과 오버헤드를 줄일 수 있다. 다만 성능 우위는 워크로드와 커널/드라이버 지원 수준에 따라 달라지며, 기존 epoll + read/write 조합보다 항상 더 빠른 것은 아니다.2. Process & ThreadProcess Control BlockstateDiagram-v2 state "New(생성)" as New state "Ready(준비)" as Ready state "Running(실행)" as Running state "Waiting(대기)" as Waiting state "Terminated(종료)" as Terminated state "Zombie(좀비)" as Zombie [*] --> New New --> Ready Ready --> Running Running --> Ready: 선점됨 Running --> Waiting: I/O 대기 Waiting --> Ready: 이벤트 완료 Running --> Terminated: exit() Terminated --> Zombie: 부모가 아직 wait 안 함 Zombie --> [*]: wait()/reap로 수거PCB(Process Control Block)는 프로세스의 메타데이터를 담는 커널 자료구조다. 프로세스 ID, 실행 상태, 레지스터 저장 영역, 메모리 매핑 정보, 열린 파일 디스크립터, 스케줄링 우선순위 등이 포함된다. 컨텍스트 스위칭 시 커널은 현재 실행 중인 프로세스의 CPU 상태를 PCB에 저장하고, 다음 프로세스의 PCB에서 상태를 복원한다.Linux에서 PCB는 task_struct 구조체로 구현되며, 그 크기는 수 KB에 달한다. 주요 필드: 프로세스 식별: pid, tgid(Thread Group ID), ppid(부모 PID), uid/gid(소유자/그룹) 실행 상태: TASK_RUNNING, TASK_INTERRUPTIBLE(대기 중, 시그널 수신 가능), TASK_UNINTERRUPTIBLE(대기 중, 시그널 무시), TASK_STOPPED, TASK_ZOMBIE CPU 상태: 범용 레지스터, 프로그램 카운터(IP), 스택 포인터(SP), 플래그 레지스터, FPU/SIMD 레지스터 메모리 관리: mm_struct 포인터(VMA 목록, 페이지 테이블 루트 pgd) 파일 시스템: files_struct(열린 파일 디스크립터 테이블), fs_struct(현재/루트 디렉터리) 스케줄링: 우선순위(prio, static_prio, normal_prio), vruntime(CFS), 스케줄링 정책 시그널: 대기 시그널 마스크, 핸들러 테이블프로세스 상태 전이: New → Ready → Running → (Waiting/Blocked) → Ready → Running → Terminated. Zombie 상태는 프로세스가 종료되었지만 부모가 아직 wait()으로 종료 상태를 수거하지 않은 상태로, task_struct가 남아 PID를 점유한다. 대량 좀비 누적은 PID 고갈로 이어질 수 있으므로, 부모 프로세스는 자식의 종료를 수거해야 한다. 부모가 먼저 종료되면 자식은 보통 init(PID 1)이나 subreaper 프로세스에 재부모화된다.Context Switchingflowchart TD A["Timer interrupt / block 이벤트 발생"] --> B[현재 레지스터를 PCB에 저장] B --> C[Scheduler가 다음 task 선택] C --> D[다음 task context 로드] D --> E["address space / stack 전환"] E --> F[실행 재개]컨텍스트 스위칭은 CPU가 한 실행 흐름(프로세스/스레드)에서 다른 실행 흐름으로 전환되는 과정이다. 레지스터 저장/복원, 스케줄러 결정, 메모리 문맥 전환(TLB/캐시 영향)이 수반되며 순수 계산 작업이 아닌 오버헤드로 작용한다. 따라서 스위칭 빈도를 줄이고 locality를 살리는 것이 시스템 성능에 유리하다.컨텍스트 스위칭의 세부 단계: 현재 프로세스 상태 저장: 범용 레지스터, PC, SP, 플래그, FPU/SIMD 상태를 PCB(또는 커널 스택)에 저장 스케줄러 실행: 런큐에서 다음 실행할 태스크를 선택 (CFS: vruntime 최소 노드) 메모리 컨텍스트 전환: cr3(x86) 레지스터에 새 프로세스의 페이지 테이블 주소를 적재. PCID 같은 최적화가 없거나 활용되지 않는 환경에서는 TLB flush 비용이 뒤따를 수 있다 새 프로세스 상태 복원: PCB에서 레지스터 값을 CPU에 로드하고 실행 재개직접 비용(레지스터 저장/복원)은 수 μs 수준이지만, 간접 비용이 훨씬 크다: TLB 플러시: 프로세스 간 전환 시 TLB 엔트리가 무효화되어 이후 접근마다 페이지 테이블 워크가 발생. PCID(Process Context ID) 지원 시 TLB 엔트리에 태그를 붙여 플러시를 최소화할 수 있다 캐시 오염(Cache Pollution): 새 프로세스의 워킹셋이 기존 캐시를 밀어내 cold start가 발생 파이프라인 플러시: 분기 예측 히스토리, 프리페치 큐가 초기화스레드 간 컨텍스트 스위칭은 보통 같은 주소 공간을 공유하므로 cr3 전환과 전체 TLB flush 비용이 없거나 훨씬 작아, 프로세스 간 전환보다 비용이 낮다. vmstat, pidstat -w, perf sched 도구로 컨텍스트 스위칭 빈도를 모니터링할 수 있다. voluntary(자발적: I/O 대기 등)와 involuntary(비자발적: 타임슬라이스 만료) 스위칭을 구분해 분석하면 병목 원인을 더 정확히 파악할 수 있다.Multi-threadingflowchart LR subgraph one_to_one["1:1 모델 (Linux NPTL)"] direction LR A1[사용자 스레드] --> AK1["Kernel Thread(커널 스레드)"] A2[사용자 스레드] --> AK2["Kernel Thread(커널 스레드)"] end subgraph n_to_one["N:1 모델 (Green Thread)"] direction LR B1[사용자 스레드] --> BK1["Kernel Thread(커널 스레드)"] B2[사용자 스레드] --> BK1 B3[사용자 스레드] --> BK1 end subgraph m_to_n["M:N 모델 (Go goroutine)"] direction LR C1[사용자 스레드] --> CK1["Kernel Thread(커널 스레드)"] C2[사용자 스레드] --> CK1 C3[사용자 스레드] --> CK2["Kernel Thread(커널 스레드)"] end멀티스레딩은 하나의 프로세스 주소 공간을 공유하는 여러 실행 단위를 병렬(또는 동시)로 운용하는 모델이다. 스레드 간 데이터 공유가 쉬워 협업 작업에 유리하지만, 공유 자원 경쟁과 동기화 복잡성이 커진다. CPU 코어 수가 늘수록 병렬 처리 이점이 커지지만, 락 경합이 심하면 오히려 성능이 저하될 수 있다.스레드 모델은 구현 수준에 따라 세 가지로 나뉜다: 1:1 모델 (커널 레벨 스레드): 사용자 스레드 하나가 커널 스레드 하나에 직접 매핑. Linux NPTL이 이 방식이다. 커널이 각 스레드를 개별 스케줄링하므로 멀티코어 활용이 자연스럽지만, 스레드 생성/전환에 커널 개입이 필요 N:1 모델 (유저 레벨 스레드): 여러 사용자 스레드가 하나의 커널 스레드에 매핑. 전환이 빠르지만 하나의 스레드가 블로킹 시스콜을 하면 전체가 멈추고, 멀티코어 활용 불가 M:N 모델 (하이브리드): M개 사용자 스레드를 N개 커널 스레드에 매핑. Go의 goroutine(G-M-P 모델)이 대표적이다. 유저 레벨 스케줄러가 goroutine을 OS 스레드에 분배하고, 블로킹 콜 시 다른 goroutine으로 전환해 코어 활용률을 높인다Amdahl’s Law는 병렬화의 한계를 표현한다: 프로그램에서 직렬 실행이 필요한 비율이 s이면, 코어 수를 아무리 늘려도 최대 속도 향상은 1/s다. 직렬 구간이 5%면 최대 20배가 한계다. 이는 멀티스레딩 설계 시 직렬 병목(락 경합, 순차 의존성) 최소화가 핵심임을 보여준다.False sharing 문제도 주의해야 한다. 서로 다른 스레드가 같은 캐시 라인에 있는 서로 다른 변수를 수정하면, 논리적으로 공유하지 않음에도 캐시 라인 무효화(cache line bouncing)가 발생해 성능이 급락한다. 패딩이나 alignas(64) 같은 캐시 라인 정렬 기법으로 방지한다.Thread vs Processflowchart TD subgraph P1[프로세스 A] T1[스레드 A1] T2[스레드 A2] M1["공유 Heap / Code 영역"] T1 --- M1 T2 --- M1 end subgraph P2[프로세스 B] T3[스레드 B1] M2["분리된 Address Space(주소 공간)"] T3 --- M2 end프로세스는 주소 공간이 분리되어 격리가 강하지만 생성/전환 비용이 상대적으로 크다. 스레드는 같은 주소 공간을 공유하므로 생성과 통신 비용이 낮지만 메모리 안전성 측면에서 사고 반경이 넓다. 실무에서는 장애 격리가 중요한 경계(서비스 단위)는 프로세스로 분리하고, 내부 병렬 처리(워크풀)는 스레드로 구성하는 전략을 자주 사용한다. 비교 항목 Process Thread 주소 공간 독립 (격리) 공유 (코드, 데이터, 힙) 생성 비용 높음 (fork → COW 최적화) 낮음 (pthread_create) 통신 IPC 필요 (pipe, socket, shm) 공유 메모리 직접 접근 컨텍스트 스위칭 비쌈 (TLB flush, cr3 전환) 상대적 저렴 장애 격리 강함 (한 프로세스 크래시 무관) 약함 (한 스레드 오류가 전체 영향) fork()는 부모 프로세스를 복제하지만, 현대 OS는 Copy-on-Write(COW) 최적화를 적용한다. fork() 직후에는 부모/자식이 같은 물리 페이지를 공유하고, 둘 중 하나가 쓰기를 시도할 때만 해당 페이지를 복사한다. fork() 후 곧바로 exec()을 호출하는 패턴에서는 vfork()나 posix_spawn()이 더 효율적이다.IPC(Inter-Process Communication) 메커니즘: Pipe/Named Pipe: 단방향 바이트 스트림. 부모-자식 간 통신에 주로 사용 Message Queue: 구조화된 메시지 교환. 커널이 큐를 관리 Shared Memory: 가장 빠른 IPC. 동기화는 세마포어/뮤텍스로 별도 처리 필요 Socket: 네트워크 투명성을 제공. 로컬(Unix Domain Socket)도 가능 Memory-Mapped File: mmap으로 파일을 메모리에 매핑해 프로세스 간 공유3. SchedulingFCFSgantt title FCFS Example dateFormat X axisFormat %L section CPU P1 :a1, 0, 24 P2 :a2, 24, 3 P3 :a3, 27, 3FCFS(First-Come, First-Served)는 도착 순서대로 CPU를 할당한다. 구현이 단순하고 예측 가능하지만, 긴 작업이 앞에 오면 짧은 작업들이 오래 대기하는 Convoy 효과가 발생한다. 인터랙티브 시스템에서는 응답성이 떨어질 수 있다.FCFS는 비선점형(Non-preemptive) 스케줄링이다. 한번 CPU를 할당받으면 작업이 완료되거나 I/O 대기에 들어갈 때까지 CPU를 유지한다.예시: 작업 도착 순서 P1(24ms), P2(3ms), P3(3ms)FCFS: P1(0~24) → P2(24~27) → P3(27~30)평균 대기 시간: (0 + 24 + 27) / 3 = 17msSJF로 실행 시: P2(0~3) → P3(3~6) → P1(6~30)평균 대기 시간: (0 + 3 + 6) / 3 = 3msConvoy 효과의 실무 영향: 배치 처리 시스템에서 대용량 ETL 작업이 큐에 먼저 들어오면, 이후 실시간 성격의 작은 작업들이 전부 밀린다. 이를 방지하려면 최소한 우선순위 분리 또는 선점형 스케줄링이 필요하다.SJFgantt title SJF Example dateFormat X axisFormat %L section CPU P2 :b1, 0, 3 P3 :b2, 3, 3 P1 :b3, 6, 24SJF(Shortest Job First)는 실행 시간이 짧은 작업을 먼저 선택해 평균 대기 시간을 줄인다. 이론적으로 평균 대기 시간 최적에 가깝지만, 실제로는 작업 길이를 정확히 예측하기 어렵다. 긴 작업이 계속 밀릴 수 있어 starvation 가능성도 있다.SJF의 수학적 최적성은 증명 가능하다. 비선점형 스케줄링 시 평균 대기 시간을 최소화하는 최적 전략이 바로 SJF다. 실행 시간 예측은 보통 과거 CPU burst 기록을 이용한 지수 평균(Exponential Averaging)으로 수행한다:\[\tau_{n+1} = \alpha \cdot t_n + (1-\alpha) \cdot \tau_n\]여기서 $t_n$은 실제 n번째 burst 시간, $\tau_n$은 예측값, $\alpha$는 가중치(0~1)다. $\alpha$가 크면 최근 값에, 작으면 과거 히스토리에 더 비중을 둔다.선점형 변형인 SRTF(Shortest Remaining Time First)는 새 작업이 도착할 때마다 잔여 시간이 가장 짧은 작업으로 전환한다. 평균 대기 시간을 더 줄이지만 컨텍스트 스위칭 빈도가 증가하고, 긴 작업의 starvation이 더 심해질 수 있다.Round Robingantt title Round Robin (q=4) dateFormat X axisFormat %L section CPU P1 :c1, 0, 4 P2 :c2, 4, 4 P3 :c3, 8, 4 P1 :c4, 12, 4 P2 :c5, 16, 2 P3 :c6, 18, 1Round Robin은 고정된 시간 할당량(quantum)만큼 번갈아 실행한다. 시분할 환경에서 공정성과 응답성을 확보하기 좋다. 단, quantum이 너무 짧으면 컨텍스트 스위칭 오버헤드가 커지고, 너무 길면 FCFS처럼 동작해 대화형 응답성이 떨어진다.quantum 크기 결정은 시스템 설계의 핵심 파라미터다: 일반적 기준: quantum은 컨텍스트 스위칭 비용의 최소 10배 이상이어야 한다 대화형 시스템: 10~100ms 범위 (사용자 체감 응답 200ms 이내 유지) 배치 시스템: 100ms~1s 이상 (처리량 우선)RR의 응답 시간(Response Time) 특성은 양호하지만, 처리량(Turnaround Time)은 SJF보다 불리한 경우가 많다.MLFQ(Multi-Level Feedback Queue)는 RR을 확장한 실전 스케줄링이다. 여러 우선순위 큐를 두고 각 큐마다 다른 quantum 크기를 적용한다. 높은 우선순위 큐(짧은 quantum)에서 시작하고, 타임슬라이스를 소진하면 낮은 우선순위 큐(긴 quantum)로 내려간다. I/O-bound 작업(짧은 burst)은 높은 우선순위를 유지하고, CPU-bound 작업은 자연스럽게 낮은 우선순위로 이동해 공정성과 응답성을 동시에 확보한다. 주기적으로 모든 작업을 최상위 큐로 boost해 starvation을 방지하는 기법도 적용한다.Priority Schedulingflowchart TD RQ[Ready Queue] --> H[높은 우선순위] RQ --> M[중간 우선순위] RQ --> L[낮은 우선순위] H --> CPU["CPU가 가장 높은 비어 있지 않은 queue 실행"] M --> CPU L --> CPU CPU --> AGE["오래 기다린 task 우선순위 상승"] AGE --> M AGE --> H우선순위 기반 스케줄링은 높은 우선순위 작업을 먼저 실행한다. 실시간 성격의 태스크를 빠르게 처리하기 유리하지만, 낮은 우선순위 작업 기아가 생길 수 있다. 이를 완화하기 위해 대기 시간이 길수록 우선순위를 올려주는 aging 기법을 적용한다.우선순위는 정적(static) 또는 동적(dynamic)으로 설정된다: 정적 우선순위: 프로세스 생성 시 결정되며 변경 불가. 실시간 태스크(SCHED_FIFO, SCHED_RR)에서 사용 동적 우선순위: 실행 상태/대기 시간에 따라 런타임에 조정. CFS의 vruntime 기반 우선순위가 대표적Priority Inversion(우선순위 역전) 문제: 낮은 우선순위 태스크가 자원(락)을 보유한 상태에서, 중간 우선순위 태스크가 CPU를 선점하면, 높은 우선순위 태스크가 간접적으로 차단된다. 1997년 Mars Pathfinder 탐사선에서 실제 발생해 시스템 리셋이 반복된 사례가 유명하다. 해결책: Priority Inheritance: 락을 보유한 저우선순위 태스크의 우선순위를 대기 중인 고우선순위 수준으로 일시 상승 Priority Ceiling: 락에 최대 우선순위를 미리 부여Linux에서 nice 값(-20~19)은 일반 태스크 우선순위를 조정한다. 실시간 태스크는 chrt 명령이나 sched_setscheduler()로 SCHED_FIFO/SCHED_RR 정책과 우선순위(1~99)를 설정하며, 항상 일반(SCHED_NORMAL) 태스크보다 먼저 스케줄링된다.CFS (Completely Fair Scheduler)flowchart LR A[실행 가능한 task들] --> B["vruntime 기준 RB-Tree"] B --> C[가장 왼쪽 node 선택] C --> D[CPU에서 실행] D --> E[vruntime 갱신] E --> BLinux의 CFS는 가상 실행 시간(vruntime)을 기준으로 “가장 덜 실행된” 태스크를 선택해 공정성을 추구한다. Red-Black Tree로 런큐를 관리하여 삽입/삭제/선택의 균형을 유지한다. 단순한 고정 우선순위보다 일반 목적 워크로드에서 일관된 체감 성능을 제공한다.CFS의 핵심 개념: vruntime: 태스크가 소비한 가중 CPU 시간. vruntime += 실제시간 × (NICE_0_LOAD / 태스크_weight)로 계산. nice 값이 낮은(고우선순위) 태스크는 weight가 크므로 vruntime이 느리게 증가 Ideal fair scheduling 근사: CFS는 “모든 태스크가 동시에 1/N의 CPU를 사용”하는 이상적 상태를 근사한다 Scheduling Period: 태스크 수(N)가 적으면 각 태스크에 더 긴 시간을 할당하고, 많으면 최소 granularity(보통 1ms)를 보장Red-Black Tree 운용: CFS 런큐는 vruntime을 키로 하는 RB-Tree로 관리된다. 가장 왼쪽 노드(최소 vruntime)가 다음 실행 태스크다. 이 노드는 rb_leftmost 포인터로 캐싱되어 O(1)에 접근 가능하다. 삽입/삭제는 O(log N)이다.CFS의 nice 가중치 체계: nice 0의 weight는 1024이고, nice 1 차이마다 weight가 약 1.25배 변한다. 이는 “nice 1 차이 ≈ CPU 시간 약 10% 차이”를 의미하도록 설계되었다.Linux 6.6부터는 EEVDF(Earliest Eligible Virtual Deadline First)가 CFS를 대체하기 시작했다. EEVDF는 각 태스크에 가상 데드라인을 부여해 지연(latency)에 더 민감한 스케줄링을 제공하며, CFS에서 발생하던 인터랙티브 태스크의 불공정 문제를 개선한다.4. Memory ManagementPagingflowchart LR VA[가상 주소] VA --> SPLIT["VPN / Offset 분리"] SPLIT --> PT["Page Table(페이지 테이블)"] PT --> PFN["Physical Frame Number(물리 프레임 번호)"] PFN --> PA["물리 주소 = PFN + Offset"]페이징은 가상 주소 공간과 물리 메모리를 고정 크기 블록(페이지/프레임)으로 분할해 매핑한다. 외부 단편화를 줄이고, 가상 메모리 관리가 단순해진다. 대신 페이지 테이블 메모리 오버헤드와 주소 변환 비용이 발생한다.가상 주소에서 물리 주소로의 변환 과정: 가상 주소를 페이지 번호(VPN)와 오프셋으로 분리 페이지 테이블에서 VPN에 대응하는 프레임 번호(PFN)를 조회 PFN + 오프셋 = 물리 주소x86-64에서는 4단계 페이지 테이블(PML4 → PDPT → PD → PT)을 사용한다. 48비트 가상 주소를 9-9-9-9-12 비트로 분할해 각 레벨을 탐색한다.가상 주소(48비트):[PML4 index(9)] [PDPT index(9)] [PD index(9)] [PT index(9)] [Offset(12)]내부 단편화: 페이지 크기가 4KB일 때, 5KB 데이터는 2페이지를 사용하므로 3KB가 낭비된다. 페이지 크기가 클수록 내부 단편화가 커지지만 페이지 테이블 엔트리 수는 줄어든다.Huge Page: 기본 4KB 외에 2MB 또는 1GB 대형 페이지를 사용할 수 있다. TLB 엔트리 하나가 더 넓은 영역을 커버하므로 TLB miss가 감소한다. 데이터베이스나 JVM 힙에서 Huge Page 설정이 성능에 큰 영향을 준다. Linux의 Transparent Huge Pages(THP)는 자동으로 대형 페이지를 적용하지만, 할당/해제 시 레이턴시 스파이크를 유발할 수 있어 Redis 같은 지연 민감 워크로드에서는 비활성화가 권장된다.Segmentationflowchart LR LA["논리 주소 = Segment:Offset"] --> ST["Segment Table(세그먼트 테이블)"] ST --> CHK{offset < limit 인가?} CHK -->|No| EX["Segmentation Fault(세그멘테이션 폴트)"] CHK -->|Yes| BASE["물리 주소 = base + offset"] BASE --> MEM[물리 메모리 접근]세그멘테이션은 코드/데이터/스택처럼 논리 단위로 메모리를 나눠 관리한다. 개발자 관점에서 의미 있는 경계를 반영하기 좋지만, 가변 크기 할당으로 외부 단편화가 생기기 쉽다. 현대 시스템은 주로 페이징 기반이며, 세그멘테이션 개념은 보호/권한 모델 설명에 자주 등장한다.세그멘테이션에서 가상 주소는 (세그먼트 번호, 오프셋) 쌍으로 구성된다. Segment Table에서 세그먼트 번호로 base 주소와 limit(크기)를 조회한다. 오프셋 < limit 검증 후 base + 오프셋으로 물리 주소를 계산한다. limit 초과 접근은 Segmentation Fault를 발생시킨다.x86의 역사적 세그멘테이션: 초기 x86(리얼 모드)에서는 16비트 세그먼트 레지스터(CS, DS, SS, ES)로 메모리를 20비트까지 확장했다. 보호 모드에서는 GDT/LDT(Global/Local Descriptor Table)가 세그먼트별 권한(읽기/쓰기/실행), 특권 레벨(DPL), base/limit을 관리한다. x86-64에서는 세그멘테이션이 사실상 비활성화(flat model)되어 페이징만으로 메모리를 관리하지만, FS/GS 세그먼트는 TLS(Thread Local Storage) 등 특수 목적으로 여전히 사용된다.Virtual Memoryflowchart LR A[가상 주소] --> B{TLB 조회} B -->|Hit| E[물리 주소] B -->|Miss| C["Page Table Walk(페이지 테이블 탐색)"] C -->|Present| E C -->|Not Present| D["Page Fault Handler(페이지 폴트 처리)"] D --> F["disk / swap에서 page 로드"] F --> E E --> G[RAM 접근]가상 메모리는 실제 RAM보다 큰 주소 공간을 프로세스에 제공한다. 사용 빈도가 낮은 페이지는 디스크(스왑)로 내리고 필요한 시점에 다시 로드한다(demand paging). 이로써 메모리 효율과 프로세스 격리가 향상되지만, 페이지 폴트가 과도하면 심각한 성능 저하(Thrashing)가 발생한다.Demand Paging은 “필요할 때 적재”하는 전략이다. 프로세스가 처음 시작될 때 모든 페이지를 메모리에 올리지 않고, 접근이 발생하면 페이지 폴트를 통해 로드한다. 페이지 폴트 처리 과정: CPU가 페이지 테이블에서 valid bit가 0인 엔트리를 발견 → 페이지 폴트 예외 발생 커널이 폴트 원인을 판별: 유효한 접근인지(lazy allocation, 스왑 아웃) vs 무효 접근(segfault) 유효한 경우: 빈 프레임 확보(없으면 페이지 교체), 디스크에서 로드, 페이지 테이블 갱신 프로세스 재개 (폴트를 일으킨 명령어를 다시 실행)Thrashing: 물리 메모리 부족으로 페이지 교체가 빈번하면, CPU 시간 대부분이 폴트 처리에 소비된다. CPU 사용률이 낮아지고 OS가 “CPU가 놀고 있다”고 판단해 프로세스를 더 적재하여 악순환이 발생한다.Thrashing 대응: Working Set Model: 각 프로세스의 워킹셋 크기를 추적하고, 전체 합이 물리 메모리를 초과하면 일부 프로세스를 중단 PFF(Page Fault Frequency): 폴트 비율이 상한을 넘으면 프레임 추가 할당, 하한 아래면 프레임 회수 Linux OOM Killer: 메모리가 극단적으로 부족하면 oom_score가 가장 높은 프로세스를 강제 종료Page Replacementflowchart TD A[Page fault] --> B{사용 가능한 frame이 있나?} B -->|Yes| C[frame 할당] B -->|No| D[victim page 선택] D --> E{Dirty page인가?} E -->|Yes| F[disk에 write-back] E -->|No| G["write-back 생략"] F --> H[요청된 page 적재] G --> H C --> H H --> I["Page Table + TLB 갱신"]메모리가 가득 찼을 때 어떤 페이지를 내보낼지 결정하는 정책이다.LRULRU(Least Recently Used)는 최근에 사용되지 않은 페이지를 교체한다. 시간 지역성(locality)을 잘 반영하지만, 정확한 LRU 구현은 비용이 커 근사 알고리즘(Clock 등)을 주로 사용한다.정확한 LRU가 어려운 이유: 모든 메모리 접근마다 타임스탬프를 기록하는 것은 하드웨어 비용이 막대하다. 실제 OS에서는 근사 알고리즘을 사용한다: Clock Algorithm (Second-Chance): 원형 큐 + 참조 비트(reference bit). 포인터가 가리키는 페이지의 참조 비트가 1이면 0으로 리셋하고 넘어가고, 0이면 교체 대상으로 선택 Enhanced Clock (NRU): 참조 비트 + 수정 비트(dirty bit) 조합. (참조=0, 수정=0)이 가장 먼저, (참조=1, 수정=1)이 가장 마지막에 교체Linux의 페이지 교체: Active List와 Inactive List 두 개의 LRU 리스트를 운용한다. 새 페이지는 Inactive List에 삽입되고, 재참조되면 Active List로 승격된다. Active List가 가득 차면 가장 오래된 페이지가 Inactive List로 강등되고, Inactive List 끝의 페이지가 교체된다. 이 2단계 구조는 one-time access 패턴(풀 스캔 등)이 워킹셋을 밀어내는 것을 방지한다.FIFOFIFO(First In, First Out)는 가장 먼저 들어온 페이지를 내보낸다. 구현이 매우 단순하지만, 오래됐더라도 자주 쓰이는 페이지를 제거할 수 있어 비효율이 생긴다(Belady’s anomaly 가능).Belady’s Anomaly: FIFO에서는 프레임 수를 늘려도 오히려 페이지 폴트가 증가하는 비직관적 현상이 발생할 수 있다. 이는 FIFO가 “스택 성질(stack property)”을 만족하지 않기 때문이다. LRU는 스택 성질을 만족하므로 이 anomaly가 발생하지 않는다.OPT(Optimal): 미래에 가장 오랫동안 사용되지 않을 페이지를 교체한다. 이론적 최적이지만 미래 접근 패턴을 알 수 없으므로 실제 구현 불가능하다. 다른 알고리즘의 성능 평가 벤치마크로 사용된다.TLBflowchart TD A[CPU가 virtual address 발행] --> B{TLB hit 인가?} B -->|Yes| C[TLB에서 PFN 획득] B -->|No| D[Page Table Walk] D --> E[TLB entry 갱신] C --> F[물리 메모리 접근] E --> FTLB(Translation Lookaside Buffer)는 페이지 테이블 변환 결과를 캐시하는 고속 하드웨어다. TLB hit 시 주소 변환이 매우 빠르고, miss 시 페이지 테이블 워크가 발생해 지연이 커진다. 성능 분석에서 TLB miss rate은 매우 중요한 지표다.TLB 구조와 동작: 크기: L1 dTLB 64~128엔트리, L2 sTLB 512~2048엔트리. 엔트리 수가 적으므로 fully-associative 또는 high-associative 방식 Hit latency: 0~1 사이클 (파이프라인에서 병렬 처리) Miss penalty: 4단계 페이지 워크 = 4회 메모리 접근 → 수십~수백 사이클TLB 관리 이슈: TLB Flush: 컨텍스트 스위칭 시 전체 플러시(비용 큼) 또는 PCID로 선택적 관리 TLB Shootdown: 멀티코어에서 한 코어가 페이지 테이블을 변경하면, 다른 코어의 TLB에 캐시된 구 엔트리도 무효화해야 한다. IPI(Inter-Processor Interrupt)로 다른 코어에 삭제를 요청하며, 대규모 멀티코어 시스템에서 심각한 병목이 될 수 있다 ASID(Address Space ID, ARM): TLB 엔트리에 ASID 태그를 부여해 컨텍스트 스위칭 시 플러시 없이 엔트리를 구분TLB 친화적 설계: Huge Page 사용(하나의 엔트리가 2MB~1GB 커버), 메모리 접근 패턴 최적화(순차 접근, 루프 타일링), 워킹셋을 TLB 커버리지 내로 유지하는 것이 중요하다.5. ConcurrencyRace ConditionsequenceDiagram participant T1 as Thread 1(스레드 1) participant X as Shared Counter(공유 카운터) participant T2 as Thread 2(스레드 2) T1->>X: read x=0 T2->>X: read x=0 T1->>X: write x=1 T2->>X: write x=1 Note over X: Expected 2, actual 1Race Condition은 여러 실행 흐름이 공유 상태를 동시 접근할 때, 실행 순서에 따라 결과가 달라지는 문제다. 테스트에서 재현이 어렵고 운영 환경에서 간헐적으로 나타나 디버깅 난도가 높다. 원자성 보장, 불변 구조 사용, 임계구역 최소화가 핵심 대응 전략이다.Race Condition의 구체적 유형: Check-then-Act: 조건 확인과 행동 사이에 다른 스레드가 상태를 변경. 예: if (map.containsKey(key)) map.get(key) — 두 호출 사이에 키가 삭제 가능 Read-Modify-Write: count++는 실제로 read → increment → write 3단계. 두 스레드가 동시에 같은 값을 읽어 각각 +1하면 결과는 +1 (Lost Update) TOCTOU: 파일 존재 확인 후 열기 사이에 다른 프로세스가 파일을 조작 가능. 보안 취약점으로 이어질 수 있다// Lost Update 예시 → 원자적 연산으로 해결// 문제: 스레드 A, B가 동시에 counter=0을 읽어 각각 +1 → 결과 1 (기대: 2)atomic_fetch_add(&counter, 1); // 하드웨어 CAS 기반 원자적 증가Memory Ordering 문제: 컴파일러 최적화(명령어 재배치)와 CPU의 out-of-order 실행으로, 기대하는 실행 순서와 실제 메모리 접근 순서가 다를 수 있다. Memory Barrier(fence)와 std::atomic(C++), volatile(Java) 같은 메커니즘으로 순서를 강제해야 한다.MutexstateDiagram-v2 [*] --> Unlocked Unlocked --> Locked: lock() Locked --> Locked: 다른 스레드 lock() 요청 (blocked) Locked --> Unlocked: unlock()Mutex는 상호 배제를 제공해 한 시점에 하나의 스레드만 임계구역에 들어가게 한다. 사용이 직관적이지만 락 범위가 넓으면 병렬성이 감소한다. 락 획득/해제 순서 규칙을 정하지 않으면 데드락 위험이 커진다.Mutex 구현 내부: Futex (Linux): 비경합 경로에서는 사용자 공간 CAS만으로 처리하고, 경합 시에만 futex() 시스템 콜로 커널에 대기/깨움을 요청. 대부분의 락 획득이 비경합 상태이므로 시스콜 횟수가 크게 줄어든다 Adaptive Mutex: 경합 시 짧은 스핀 후 슬립으로 전환. 락 보유자가 다른 CPU에서 실행 중이면 곧 해제될 가능성이 높으므로 스핀이 유리pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;pthread_mutex_lock(&lock); // 획득 (블로킹)shared_count++; // 임계구역pthread_mutex_unlock(&lock); // 해제// trylock: 비블로킹 시도if (pthread_mutex_trylock(&lock) == 0) { // 성공 → 임계구역 pthread_mutex_unlock(&lock);}Recursive Mutex는 같은 스레드가 동일 뮤텍스를 여러 번 획득 가능(내부 카운터 관리). 재귀 함수에서 데드락을 방지하지만, 설계 결함을 숨길 수 있어 가급적 사용을 피하는 것이 권장된다.Semaphoreflowchart TD A["sem_wait()"] --> B{count > 0 인가?} B -->|Yes| C["count 감소 후 진입"] B -->|No| D[wait queue에서 block] E["sem_post()"] --> F["count 증가"] F --> G{waiter가 있나?} G -->|Yes| H[waiter 하나 깨우기]세마포어는 정수 카운터 기반 동기화 도구로, 동시에 접근 가능한 자원 수를 제어할 때 유용하다. binary semaphore는 mutex와 유사하게 쓸 수 있고, counting semaphore는 풀(pool) 자원 제한에 적합하다.세마포어의 핵심 연산 (Dijkstra 정의): P(wait/down): 카운터 > 0이면 1 감소하고 진행. 0이면 양수가 될 때까지 대기 V(signal/up): 카운터를 1 증가시키고, 대기 중인 스레드가 있으면 하나를 깨움세마포어 vs 뮤텍스 핵심 차이: 소유권: 뮤텍스는 획득한 스레드만 해제 가능. 세마포어는 다른 스레드가 signal 가능 시그널링: 세마포어는 스레드 간 이벤트 통지에 사용 가능 (생산자가 signal, 소비자가 wait) 우선순위 상속: 뮤텍스는 Priority Inheritance를 지원할 수 있지만, 세마포어는 소유 개념이 없어 지원이 어렵다// Counting Semaphore로 커넥션 풀 제한 (최대 10개)sem_t pool;sem_init(&pool, 0, 10);void get_connection() { sem_wait(&pool); // 카운터 감소. 0이면 대기 (풀 고갈) // 커넥션 사용}void release_connection() { sem_post(&pool); // 카운터 증가. 대기 스레드 깨움}Monitorflowchart TD ENTER["thread가 monitor 진입"] --> LOCK["암묵적 mutual-exclusion lock"] LOCK --> CS["critical section 실행"] CS --> WAIT{"조건 충족?"} WAIT -->|No| CVWAIT["wait 후 lock 반납"] CVWAIT --> WAKE["대기 thread 깨우기"] WAKE --> LOCK WAIT -->|Yes| EXIT["monitor 종료"]모니터는 락과 조건 변수를 추상화한 고수준 동기화 모델이다. 공유 상태 접근을 특정 모니터 내부 메서드로 제한해 안전성을 높인다. Java의 synchronized + wait/notify 조합이 전형적인 모니터 패턴이다.모니터의 두 가지 시맨틱: Hoare Monitor: signal 호출 시 즉시 대기 스레드에 제어를 넘긴다. 깨어난 스레드는 조건이 반드시 참인 상태에서 실행 재개. 구현이 복잡 Mesa Monitor: signal은 대기 스레드를 ready 상태로 옮기기만 한다. 실행 재개 시 다른 스레드가 조건을 변경할 수 있으므로, while 루프로 조건 재확인 필수. Java, POSIX의 표준 방식// Java Monitor 패턴 - Mesa 시맨틱class BoundedBuffer<T> { private final Queue<T> queue = new LinkedList<>(); private final int capacity; synchronized void put(T item) throws InterruptedException { while (queue.size() == capacity) wait(); // while 필수: spurious wakeup 대비 queue.add(item); notifyAll(); } synchronized T take() throws InterruptedException { while (queue.isEmpty()) wait(); T item = queue.remove(); notifyAll(); return item; }}Spurious Wakeup: OS 구현 특성상 signal/notify 없이도 wait에서 깨어날 수 있다. 따라서 조건 확인은 반드시 while 루프로 감싸야 한다.Deadlockgraph LR P1((P1)) -->|보유| R1["R1 lock"] R1 -->|요청| P2((P2)) P2 -->|보유| R2["R2 lock"] R2 -->|요청| P1데드락은 서로가 상대 자원을 기다리며 영원히 진행하지 못하는 상태다. 예방(조건 제거), 회피(안전 상태 유지), 탐지/복구 전략으로 대응한다.Coffman Condition데드락이 발생하려면 보통 다음 4가지 조건이 동시에 성립한다: 상호 배제, 점유 대기, 비선점, 순환 대기. 따라서 실무에서는 자원 획득 순서를 전역 규칙으로 고정해 순환 대기를 깨는 방식이 가장 흔하다.각 조건의 해제 전략: 상호 배제 제거: 자원을 공유 가능하게 만든다 (예: read-only). 본질적으로 배타적 자원에는 적용 불가 점유 대기 제거: 필요한 모든 자원을 한 번에 요청 (all-or-nothing). 자원 활용률 저하, starvation 가능 비선점 제거: 자원을 강제로 빼앗기 허용. CPU에는 적합하나 프린터/파일에는 부적합 순환 대기 제거: 자원에 전역 순서를 부여하고 항상 오름차순으로만 획득. 가장 실용적Banker’s Algorithm (회피): 자원 요청 시 “안전 상태(safe state)”를 유지하는지 확인하고, 안전하다면 할당, 아니면 대기시킨다.탐지/복구: 자원 할당 그래프에서 사이클이 존재하면 데드락. 비용이 가장 적은 트랜잭션/프로세스를 롤백해 해소한다. DB 시스템은 wait-for graph를 주기적으로 검사한다.Livelock: 데드락과 달리 프로세스들이 상태를 계속 변경하지만 유용한 진전이 없는 상태. 랜덤 백오프로 완화 가능.6. File SystemInodeflowchart LR PATH["/home/user/a.txt"] --> DIR["디렉터리 엔트리<br/>name → inode#"] DIR --> INO["inode #12345"] INO --> META["메타데이터<br/>mode / uid / size / timestamps"] INO --> PTR["data block 포인터"] PTR --> D1[(Block 1)] PTR --> D2[(Block 2)] PTR --> D3[(Block N)]inode는 파일의 메타데이터(권한, 소유자, 크기, 타임스탬프, 데이터 블록 포인터 등)를 저장하는 구조다. 파일명은 디렉터리 엔트리에 있고, inode는 실제 데이터 위치를 가리킨다. 하드링크는 같은 inode를 여러 이름으로 참조하는 메커니즘이다.inode의 상세 구조 (ext4 기준): 메타데이터: 파일 타입(일반/디렉터리/심볼릭링크/소켓), 권한(rwx), uid/gid, 크기, 링크 카운트 타임스탬프: atime(최근 접근), mtime(최근 수정), ctime(메타데이터 변경), crtime(생성 시간) 데이터 블록 포인터 (전통적 방식): 직접 포인터 12개: 각각 하나의 데이터 블록을 가리킴 단일 간접: 포인터 블록을 거쳐 데이터 블록으로 (4KB/4B = 1024개 추가 블록) 이중 간접: 두 단계 포인터 (1024² ≈ 1M 추가 블록) 삼중 간접: 세 단계 포인터 (1024³ ≈ 1G 추가 블록) ext4에서는 전통적 블록 포인터 대신 extent 구조를 사용한다. extent는 (시작 블록, 길이) 쌍으로 연속 블록을 표현해, 대용량 파일을 적은 메타데이터로 효율적으로 관리한다.하드링크 vs 심볼릭링크: 하드링크: 동일 inode를 가리키는 또 다른 디렉터리 엔트리. 링크 카운트 0이 되어야 실제 삭제. 파일 시스템 경계를 넘을 수 없고, 디렉터리에는 적용 불가(루프 방지) 심볼릭링크: 별도 inode를 가지며 원본 경로 문자열을 저장. 경계를 넘을 수 있지만, 원본 삭제 시 dangling link 발생Directory Structuregraph TD ROOT["/"] ROOT --> ETC["/etc"] ROOT --> HOME["/home"] HOME --> U1["/home/alice"] U1 --> DOC["~/docs"] U1 --> BIN["~/bin"] ROOT --> VAR["/var"] VAR --> LOG["/var/log"]디렉터리는 “이름 -> inode 번호” 매핑 테이블로 볼 수 있다. 트리 구조를 통해 계층적 네임스페이스를 제공하고 경로 탐색 성능/무결성을 위해 다양한 인덱싱 기법이 사용된다.디렉터리 구현 방식: 선형 리스트: 단순하지만 파일 수가 많으면 탐색 O(n). 초기 Unix 방식 해시 테이블: 이름을 해시해 O(1) 조회. 충돌 관리 필요 B-Tree/HTree: ext3/ext4의 dir_index는 HTree(해시 기반 B-Tree)로 수만~수십만 파일에서도 빠른 조회 제공경로 해석(Path Resolution): /home/user/data.txt 접근 시 커널은 루트 inode에서 시작해 각 디렉터리를 순차 탐색한다. 각 단계마다 권한 확인, 마운트 포인트 교차, 심볼릭링크 추적이 필요하다. dentry cache(dcache)가 이 과정을 가속한다.VFS(Virtual File System): Linux는 VFS 추상 계층을 두어 다양한 파일 시스템(ext4, XFS, NFS, tmpfs 등)을 동일한 인터페이스로 접근할 수 있게 한다. superblock, inode, dentry, file 네 가지 객체가 VFS의 핵심 추상화다.JournalingsequenceDiagram participant App participant FS as Filesystem(파일시스템) participant J as Journal(저널) participant D as Data Area(데이터 영역) App->>FS: write metadata + data FS->>J: append intent/transaction FS->>J: commit record FS->>D: apply changes (checkpoint) Note over FS,J: Crash before checkpoint -> replay journal저널링 파일시스템은 메타데이터(또는 데이터까지) 변경 로그를 먼저 기록한 후 실제 반영한다. 전원 장애 등 비정상 종료 후 복구 시간을 크게 줄이고 일관성을 개선한다. 다만 로그 기록 오버헤드가 존재한다.저널링 모드: Journal (Full): 메타데이터 + 데이터 모두 저널에 기록 후 실제 위치에 반영. 가장 안전하지만 쓰기 성능이 절반(이중 쓰기) Ordered (ext4 기본): 데이터를 먼저 실제 위치에 쓴 후, 메타데이터를 저널에 기록. 순서 보장으로 데이터 일관성 유지 Writeback: 메타데이터만 저널링하고 순서를 보장하지 않는다. 가장 빠르지만 크래시 후 stale data 노출 가능저널 복구: 완료 표시(commit record)가 있는 트랜잭션은 redo(재반영), 없는 트랜잭션은 무시(자연 undo). 저널은 순환 버퍼로 운용되며 가득 차면 체크포인팅으로 공간을 확보한다.ext4 / NTFS 구조flowchart LR EXT4[ext4] --> E1[블록 그룹] EXT4 --> E2["inode table + journal 영역"] EXT4 --> E3["extent 기반 할당"] NTFS[NTFS] --> N1["MFT (Master File Table)"] NTFS --> N2["USN Journal + LogFile"] NTFS --> N3["attribute / stream 구조"]ext4는 inode 기반 + 저널링 + extent를 활용해 Linux 환경에서 범용적으로 사용된다. NTFS는 MFT(Master File Table) 중심 구조를 가지며 Windows 권한 모델, 저널링, 대용량 파일 처리에 강점을 가진다. 두 파일시스템 모두 메타데이터 중심 설계를 통해 장애 복구와 성능 균형을 맞춘다.ext4 상세: 블록 그룹: 파일 시스템을 여러 블록 그룹으로 분할해 관련 데이터/메타데이터를 물리적으로 근접 배치. 디스크 헤드 이동 감소 Delayed Allocation: 쓰기 요청 시 즉시 블록을 할당하지 않고 flush 시점까지 지연. 연속 블록 할당 확률을 높이고 단편화 감소 최대 파일 크기: 16TB (4KB 블록 기준), 최대 파일 시스템 크기: 1EBNTFS 상세: MFT: 모든 파일/디렉터리 정보를 MFT 레코드(보통 1KB)에 저장. 작은 파일은 MFT 레코드 안에 데이터까지 포함(resident attribute) $LogFile: NTFS 저널. USN 저널로 변경 이력 추적도 지원 Alternate Data Streams: 하나의 파일에 여러 데이터 스트림 부착 가능. 메타데이터 확장에 유용하나 보안 리스크 존재 ACL 기반 권한: Unix rwx보다 세밀한 권한 제어 가능7. I/O SystemInterruptsequenceDiagram participant Dev as Device participant ICU as APIC/Interrupt Ctrl participant CPU as CPU participant ISR as ISR (Top Half) participant BH as Bottom Half Dev->>ICU: interrupt signal ICU->>CPU: vector delivery CPU->>ISR: enter handler ISR->>BH: defer heavy work ISR-->>CPU: iret BH->>CPU: process queued work인터럽트는 하드웨어/소프트웨어 이벤트가 CPU의 현재 흐름을 잠시 중단하고 인터럽트 핸들러를 실행하게 하는 메커니즘이다. 폴링보다 효율적이며, 네트워크 패킷 도착/디스크 I/O 완료 통지에 핵심적이다.인터럽트 종류: 하드웨어 인터럽트(외부): 디바이스가 IRQ line으로 CPU에 이벤트 통지. 키보드, 네트워크, 타이머 등 소프트웨어 인터럽트(트랩): 프로그램이 의도적으로 발생. 시스템 콜(syscall), 디버그 브레이크포인트(int 3) 예외(Exception): CPU가 명령 실행 중 오류 감지. 페이지 폴트(#PF), 0으로 나누기(#DE), 보호 위반(#GP)인터럽트 처리 흐름: 디바이스가 인터럽트 컨트롤러(APIC)에 신호 전송 컨트롤러가 우선순위 판단 후 CPU에 인터럽트 전달 CPU가 현재 상태를 스택에 저장하고 IDT(Interrupt Descriptor Table)에서 핸들러 주소 조회 핸들러 실행 (Top-Half: 최소 작업만 빠르게 처리) Bottom-Half로 무거운 처리를 지연 실행 (softirq, tasklet, workqueue) 인터럽트 복귀(iret)Top-Half / Bottom-Half 분리: 핸들러 내에서 오래 걸리는 작업을 수행하면 다른 인터럽트가 차단되어 응답성이 저하된다. 따라서 핸들러는 최소 작업만 수행하고 나머지를 Bottom-Half로 지연시킨다: Softirq: 정적 정의, 높은 우선순위. 네트워크 스택에 사용 Tasklet: softirq 위에 구현. 동일 tasklet의 동시 실행 방지 보장 Workqueue: 커널 스레드 컨텍스트 실행. 슬립 가능Interrupt Coalescing / NAPI: 고속 네트워크에서 패킷마다 인터럽트를 발생시키면 CPU가 압도당한다. NAPI는 첫 패킷에서 인터럽트를 받은 후 폴링 모드로 전환해 배치 처리하고, 큐가 비면 인터럽트 모드로 돌아간다.DMAsequenceDiagram participant CPU participant DMA participant DEV as Device participant RAM CPU->>DMA: setup src/dst/len DMA->>DEV: start transfer DEV->>RAM: direct data transfer DMA-->>CPU: interrupt on completionDMA(Direct Memory Access)는 CPU 개입을 최소화하고 장치가 메모리에 직접 데이터를 전송하도록 한다. 대량 I/O에서 CPU 부하를 줄이고 처리량을 높이는 데 효과적이다.DMA 전송 과정: CPU가 DMA 컨트롤러에 전송 정보 설정 (소스/목적지 주소, 크기, 방향) DMA 컨트롤러가 버스를 사용해 장치-메모리 간 직접 전송 완료 후 인터럽트로 CPU에 통지 CPU는 전송 동안 다른 작업 수행 가능DMA 모드: Block Transfer: 버스를 독점해 연속 전송. 빠르지만 CPU 버스 접근 차단 Cycle Stealing: 한 워드만 전송 후 버스 반환. CPU와 번갈아 사용 Scatter-Gather DMA: 물리적으로 불연속인 여러 메모리 영역을 하나의 DMA 연산으로 처리. 네트워크 패킷(헤더+페이로드), 파일 I/O에 유용IOMMU: DMA의 물리 주소 직접 사용은 보안 위험. IOMMU는 장치 DMA 주소를 가상 주소로 변환해 허가된 영역만 접근하도록 제한한다. 가상화 환경의 장치 패스스루에도 필수적이다.Buffer Cacheflowchart TD R["read(fd)"] --> HIT{page cache hit 인가?} HIT -->|Yes| RET[캐시된 데이터 반환] HIT -->|No| IO[disk에서 읽기] IO --> FILL[page cache 채우기] FILL --> RET W["write(fd)"] --> DIRTY[dirty page 표시] DIRTY --> WB[background writeback] WB --> DISK[(disk)]버퍼 캐시는 디스크 블록을 메모리에 캐싱해 반복 I/O 지연을 줄인다. 파일 읽기 성능 체감에 큰 영향을 주며, write-back 정책에서는 지연 쓰기를 통해 성능을 높이되 내구성 보장을 위해 fsync 전략이 중요하다.Linux Page Cache: read(): page cache에 있으면 즉시 반환(hit). 없으면 디스크에서 읽어 cache에 저장 후 반환(miss) write(): page cache의 해당 페이지를 수정하고 dirty로 표시. 실제 디스크 쓰기는 지연Dirty Page Flush 정책: 주기적: writeback 커널 스레드가 기본 30초 주기로 dirty page를 디스크에 기록 비율 기반: dirty page 비율이 임계치(10%) 초과 시 background writeback 시작, 더 높은 임계치(20%) 도달 시 foreground 강제 기록 명시적: fsync(fd), fdatasync(fd)로 즉시 디스크 기록. DB WAL commit에서 필수Read-Ahead: 커널이 순차 읽기 패턴을 감지하면 자동으로 후속 블록을 미리 적재. fadvise(POSIX_FADV_SEQUENTIAL) 힌트로 제어 가능.O_DIRECT: page cache를 우회해 디스크에 직접 I/O. 자체 버퍼 관리를 하는 DB에서 이중 캐싱 방지에 사용한다.8. SynchronizationSpinlockstateDiagram-v2 [*] --> Unlocked Unlocked --> Locked: atomic CAS success Locked --> Spinning: CAS fail (busy-wait) Spinning --> Locked: lock released + CAS success Locked --> Unlocked: unlock()스핀락은 락이 풀릴 때까지 스레드가 잠들지 않고 busy-wait하는 락이다. 대기 시간이 매우 짧고 컨텍스트 스위칭이 더 비쌀 때 유리하다. 커널의 짧은 임계구역에서 자주 쓰인다.// 단순 스핀락 (CAS 기반)void spin_lock(spinlock_t *lock) { while (atomic_exchange(&lock->locked, 1) == 1) { __asm__ __volatile__("pause"); // CPU 파이프라인 힌트 }}void spin_unlock(spinlock_t *lock) { atomic_store(&lock->locked, 0);}스핀락 사용 시 주의점: 단일 코어에서는 무의미: 락 보유 스레드가 실행되지 않으므로 무한 스핀. 커널에서는 스핀락 획득 시 선점(preemption) 비활성화 임계구역 내에서 슬립 금지: 다른 CPU가 오래 스핀하게 되어 성능 붕괴 인터럽트 핸들러와 공유 시: 락 획득 전에 로컬 인터럽트 비활성화 필수 (spin_lock_irqsave)고급 스핀락 변형: Ticket Spinlock: 순번 발급으로 FIFO 순서 보장. 불공정/starvation 해결 MCS/CLH Lock: 각 스레드가 자기 노드에서 로컬 변수를 스핀. 캐시 라인 경합(cache line bouncing) 감소. Linux 커널은 qspinlock(MCS 기반) 사용RW Lockflowchart TD IDLE[보유자 없음] --> R1[Reader 획득] R1 --> Rn[여러 Reader 허용] IDLE --> W[Writer 단독 획득] Rn --> WAITW[Writer 대기] WAITW --> W W --> IDLE Rn --> IDLERW Lock(Read-Write Lock)은 읽기 다중 허용, 쓰기 단독 허용 정책을 제공한다. 읽기 비중이 압도적으로 높은 워크로드에서 처리량 개선 효과가 크다. 하지만 쓰기 기아를 막기 위한 정책(writer preference 등)을 반드시 고려해야 한다.RW Lock 정책 변형: Reader Preference: 읽기 처리량 최대화. writer starvation 위험 Writer Preference: 대기 writer가 있으면 새 reader 차단. writer starvation 방지, 읽기 지연 증가 Fair (Phase-Fair): 도착 순서 존중으로 공정성 보장SeqLock: writer가 시퀀스 카운터를 증가시키고, reader는 락 없이 읽되 카운터 변경 여부로 유효성 확인. 실패 시 재시도. Linux의 jiffies 업데이트에 사용.RCU (Read-Copy-Update): Linux 커널의 핵심 동기화. 읽기 경로에서 락/원자적 연산을 전혀 사용하지 않아 극도로 빠르다. 수정 시 복사본을 만들어 수정 후 포인터를 원자적으로 교체하고, 모든 reader가 이전 버전 접근을 완료한 후(grace period) 기존 데이터를 해제한다. 읽기 99%+ 워크로드에서 최상의 확장성을 제공한다.Condition VariablesequenceDiagram participant C as Consumer(소비자) participant M as Mutex+CV participant P as Producer(생산자) C->>M: lock + wait(not_empty) M-->>C: sleep (mutex 해제됨) P->>M: lock, push item P->>M: notify_one() M-->>C: wake + mutex 재획득 C->>M: pop item and unlock조건 변수는 특정 조건이 만족될 때까지 스레드를 효율적으로 대기시키는 동기화 도구다. 보통 mutex와 함께 사용하며, wait 시 락을 원자적으로 해제하고 신호 수신 후 다시 획득한다. 바쁜 대기 없이 생산자-소비자 패턴을 구현할 때 필수적이다.조건 변수 핵심 속성: 원자적 해제+대기: wait 호출 시 뮤텍스 해제와 슬립이 원자적으로 수행. 분리되면 해제 후 슬립 전에 signal을 놓치는 “lost wakeup” 발생 Spurious wakeup: POSIX 표준은 signal 없이도 wait에서 반환될 수 있음을 명시. 조건 확인은 반드시 while 루프로 감싸야 한다 signal vs broadcast: signal은 하나만, broadcast는 모든 대기 스레드를 깨운다. 잘못된 선택은 lost wakeup 또는 thundering herd 유발// 생산자-소비자 (크기 제한 포함)std::mutex m;std::condition_variable cv_not_full, cv_not_empty;std::queue<int> q;const int MAX_SIZE = 100;void producer(int v) { std::unique_lock<std::mutex> lock(m); cv_not_full.wait(lock, [] { return q.size() < MAX_SIZE; }); q.push(v); cv_not_empty.notify_one();}int consumer() { std::unique_lock<std::mutex> lock(m); cv_not_empty.wait(lock, [] { return !q.empty(); }); int v = q.front(); q.pop(); cv_not_full.notify_one(); return v;}Thundering Herd: broadcast로 모든 스레드를 깨웠을 때 실제 조건을 만족하는 스레드가 하나뿐이면 나머지는 뮤텍스 경합 후 다시 대기한다. 가능하면 signal(하나만 깨움)을 사용하되, 복수 스레드가 동시에 진행할 수 있는 경우에만 broadcast를 사용한다.9. Virtualization & ContainersHypervisor & VMflowchart TD HW[물리 하드웨어] HW --> HV["Hypervisor(하이퍼바이저)"] HV --> VM1["VM 1: Guest OS + 앱"] HV --> VM2["VM 2: Guest OS + 앱"] HV --> VM3["VM 3: Guest OS + 앱"]가상화는 물리 하드웨어 위에 여러 독립적인 실행 환경을 만드는 기술이다. 서버 통합, 격리, 이식성을 위해 사용한다.하이퍼바이저 유형: Type 1 (Bare-Metal): 하드웨어 위에 직접 실행. Xen, VMware ESXi, Microsoft Hyper-V, KVM. 성능이 좋고 프로덕션 서버에 사용 Type 2 (Hosted): 호스트 OS 위에서 애플리케이션으로 실행. VirtualBox, VMware Workstation, Parallels. 개발/테스트용가상화 기법: 전가상화(Full Virtualization): 게스트 OS 수정 없이 실행. 특권 명령을 하이퍼바이저가 가로채서(trap-and-emulate) 에뮬레이션. CPU의 하드웨어 가상화 지원(Intel VT-x, AMD-V)이 핵심 반가상화(Paravirtualization): 게스트 OS 커널을 수정해 하이퍼바이저의 API(hypercall)를 직접 호출. 트랩 오버헤드 감소. Xen이 대표적 하드웨어 지원 가상화: CPU가 게스트/호스트 모드를 하드웨어로 구분 (VMX root/non-root). VMCS(Virtual Machine Control Structure)로 컨텍스트 관리. 현대 가상화의 기반메모리 가상화: Shadow Page Table: 하이퍼바이저가 게스트 페이지 테이블과 물리 매핑의 합성 테이블을 유지. 오버헤드 큼 EPT/NPT (Extended/Nested Page Tables): CPU가 2단계 주소 변환(GVA→GPA→HPA)을 하드웨어로 처리. TLB miss 비용은 증가하지만 전체 성능은 크게 개선. Intel EPT, AMD NPT 메모리 Ballooning: 게스트 내부의 balloon driver가 사용하지 않는 메모리를 호스트에 반환. 가상 머신 간 동적 메모리 재분배I/O 가상화: 에뮬레이션: 하이퍼바이저가 가상 장치를 소프트웨어로 구현. 호환성 높지만 느림 Virtio (반가상화 I/O): 게스트가 가상 장치 드라이버를 사용해 하이퍼바이저와 효율적으로 통신. 공유 메모리 기반 vring으로 I/O 오버헤드 최소화 SR-IOV: 물리 NIC가 여러 가상 기능(VF)을 하드웨어 수준에서 제공. 하이퍼바이저를 우회해 네이티브에 가까운 I/O 성능Linux Namespaceflowchart LR P[컨테이너 프로세스] P --> PID[PID Namespace] P --> NET[NET Namespace] P --> MNT[MNT Namespace] P --> UTS[UTS Namespace] P --> IPC[IPC Namespace] P --> USER[USER Namespace]네임스페이스는 프로세스가 보는 시스템 리소스의 범위(view)를 격리하는 커널 기능이다. 컨테이너 기술의 핵심 빌딩 블록이다. 네임스페이스 격리 대상 플래그 설명 PID 프로세스 ID CLONE_NEWPID 컨테이너 내부에서 PID 1부터 시작 NET 네트워크 스택 CLONE_NEWNET 독립된 네트워크 인터페이스, IP, 라우팅 MNT 파일 시스템 마운트 CLONE_NEWNS 독립된 마운트 트리 (chroot의 발전형) UTS 호스트명/도메인명 CLONE_NEWUTS 컨테이너별 독립 hostname IPC IPC 리소스 CLONE_NEWIPC 독립된 SysV IPC, POSIX 메시지 큐 USER UID/GID 매핑 CLONE_NEWUSER 컨테이너 내 root가 호스트에서는 일반 사용자 CGROUP cgroup 뷰 CLONE_NEWCGROUP /proc/self/cgroup에서 보이는 계층 격리 TIME (5.6+) 시스템 시간 CLONE_NEWTIME CLOCK_MONOTONIC/BOOTTIME 오프셋 네임스페이스 생성/참여: clone(): 새 프로세스 생성 시 네임스페이스 플래그 전달 unshare(): 현재 프로세스를 새 네임스페이스로 분리 setns(): 기존 네임스페이스에 참여 (컨테이너 진입)호스트에서 보면: 컨테이너 내부에서 보면:PID 1 (init) PID 1 (컨테이너의 init 프로세스)├── PID 100 ├── PID 2 (nginx)│ └── PID 101 └── PID 3 (worker)│ PID 102│ (= 컨테이너 PID 1)Linux Cgroup (Control Groups)flowchart TD ROOT["/sys/fs/cgroup"] ROOT --> SVC["service.slice"] SVC --> CTR["container.scope"] CTR --> CPU["cpu.max"] CTR --> MEM["memory.max"] CTR --> IO["io.max"] CTR --> PIDS["pids.max"]cgroup은 프로세스 그룹의 리소스 사용량을 제한/모니터링/격리하는 커널 기능이다. 네임스페이스가 “보이는 범위”를 격리한다면, cgroup은 “사용할 수 있는 양”을 제한한다.cgroup v2 (통합 계층, Linux 4.5+): 컨트롤러 기능 설정 예시 cpu CPU 시간 제한/가중치 cpu.max "200000 100000" → 200ms/100ms (200% = 2코어) memory 메모리 제한, OOM 관리 memory.max 536870912 → 512MB 제한 io 블록 I/O 대역폭/IOPS 제한 io.max "8:0 rbps=1048576" pids 최대 프로세스 수 제한 pids.max 100 cpuset CPU/NUMA 핀닝 cpuset.cpus "0-3" → CPU 0~3만 사용 cgroup v1 vs v2: v1: 컨트롤러별 독립 계층. 하나의 프로세스가 여러 계층에 다른 그룹으로 속할 수 있음. 복잡 v2: 단일 통합 계층. 프로세스는 정확히 하나의 cgroup에만 속함. 위임(delegation)이 안전. systemd와의 통합이 자연스러움OOM Killer와 메모리 cgroup: memory.max 초과 시 cgroup 내부에서만 OOM Kill 발생 (다른 컨테이너에 영향 없음) memory.low: 보호 임계값. 이 값 이하의 메모리는 전역 회수 대상에서 제외 (soft guarantee) memory.high: 조절 임계값. 초과 시 프로세스가 직접 메모리 회수를 수행하게 해 속도를 늦춤Container Runtime (Docker/OCI)flowchart TD CLI[docker run] --> CD[containerd] CD --> SHIM["containerd-shim"] SHIM --> RUNC[runc] RUNC --> NS[namespaces 설정] RUNC --> CG[cgroups 설정] RUNC --> PROC[컨테이너 프로세스 시작]컨테이너는 네임스페이스 + cgroup + 루트 파일시스템 격리의 조합이다. VM과 달리 커널을 공유해 시작 시간이 빠르고 오버헤드가 작다.컨테이너 아키텍처 스택:┌─────────────────────────────────┐│ User: docker run / kubectl │├─────────────────────────────────┤│ High-level Runtime: containerd │ ← 이미지 관리, 컨테이너 라이프사이클├─────────────────────────────────┤│ Low-level Runtime: runc │ ← OCI 스펙에 따라 네임스페이스/cgroup 설정├─────────────────────────────────┤│ Linux Kernel │ ← namespaces, cgroups, seccomp, capabilities└─────────────────────────────────┘OCI (Open Container Initiative) 스펙: Runtime Spec: 컨테이너 실행 환경 정의 (config.json: 네임스페이스, cgroup, 마운트, 프로세스) Image Spec: 컨테이너 이미지 포맷 (레이어 기반, content-addressable) Distribution Spec: 이미지 레지스트리 APIUnion Filesystem (OverlayFS): 컨테이너 이미지를 읽기 전용 레이어들의 스택으로 구성 컨테이너 실행 시 최상위에 쓰기 가능 레이어 추가 (Copy-on-Write) 동일 이미지에서 여러 컨테이너를 실행해도 읽기 전용 레이어 공유 → 디스크/메모리 절약VM vs Container: 비교 VM Container 격리 수준 하드웨어 수준 (강함) 프로세스 수준 (약함) 부팅 시간 수십 초~분 밀리초~초 메모리 오버헤드 수백 MB (게스트 OS 커널) 수 MB 커널 각 VM 독립 커널 호스트 커널 공유 보안 강한 격리 커널 공유로 공격 표면 넓음 사용 사례 멀티 OS, 강한 격리 필요 마이크로서비스, CI/CD, 스케일링 컨테이너 보안 강화: seccomp: 컨테이너가 사용할 수 있는 시스템 콜을 제한 (기본: Docker의 seccomp 프로파일이 ~44개 syscall 차단) AppArmor/SELinux: 파일 접근, 네트워크 등 MAC(Mandatory Access Control) 정책 Rootless Container: 호스트의 root 권한 없이 컨테이너 실행 (User Namespace 활용) gVisor/Kata Containers: 커널 공유의 보안 약점을 보완. gVisor는 사용자 공간 커널(Sentry), Kata는 경량 VM으로 컨테이너 실행10. Boot ProcessUEFI & Boot Sequenceflowchart TD A[전원 켜짐] --> B[UEFI POST] B --> C["Bootloader (GRUB)"] C --> D["Kernel + initramfs"] D --> E[Kernel 초기화] E --> F["switch_root / rootfs mount"] F --> G[PID 1 systemd] G --> H["서비스 시작 + 로그인"]시스템 전원 투입부터 사용자 로그인까지의 부팅 과정은 면접에서 시스템 이해도를 평가하는 중요 주제다.전원 투입 → 펌웨어 (BIOS/UEFI) Power-On Self-Test (POST): CPU 리셋 벡터에서 펌웨어 코드 실행. 메모리(DRAM 초기화), 핵심 하드웨어 점검 펌웨어 초기화: UEFI 드라이버 로딩, PCIe 장치 열거, USB 컨트롤러 초기화BIOS vs UEFI: 비교 Legacy BIOS UEFI 비트 모드 16-bit Real Mode 시작 32/64-bit Protected Mode 디스크 지원 MBR (최대 2TB, 4파티션) GPT (최대 9.4ZB, 128파티션) 부팅 방식 MBR 부트 섹터 실행 EFI System Partition의 .efi 파일 실행 보안 부팅 없음 Secure Boot (서명 검증 체인) 인터페이스 텍스트 기반 GUI 가능, 마우스 지원 드라이버 INT 13h (BIOS 인터럽트) UEFI 드라이버 모델 (EFI Byte Code) Secure Boot: UEFI 펌웨어가 부트로더/커널의 디지털 서명을 검증. Microsoft의 UEFI CA 키가 기본 신뢰 체인. Linux 배포판은 별도의 shim 부트로더로 서명 체인을 구성한다.부트로더 (Bootloader)UEFI가 EFI System Partition(ESP, FAT32)에서 부트로더를 로드한다.GRUB2 (Grand Unified Bootloader): GRUB core 이미지 로드 (EFI stub 또는 MBR → core.img) 파일 시스템 드라이버로 /boot/grub/grub.cfg 읽기 부팅 메뉴 표시 (커널 선택, 커널 파라미터 편집 가능) 선택한 커널 이미지(vmlinuz)와 초기 램디스크(initramfs/initrd) 로드 커널에 제어 전달커널 파라미터 예: root=/dev/sda2 ro quiet splash init=/sbin/init커널 초기화 압축 해제 & 자기 재배치: vmlinuz의 압축된 커널을 메모리에 풀어 실행 아키텍처 초기화: 페이징 설정, GDT/IDT 구성, CPU 감지 (마이크로아키텍처, 기능 플래그) 메모리 관리 초기화: 물리 메모리 맵(E820/UEFI Memory Map) 파싱, 버디 할당기/SLAB 할당기 초기화 initramfs 마운트: tmpfs에 initramfs를 풀어 임시 루트 파일시스템으로 사용. 실제 루트 파일시스템을 마운트하는 데 필요한 드라이버(RAID, LVM, 파일시스템, 암호화)를 여기서 로드 실제 루트 마운트: initramfs 내의 init 스크립트가 실제 루트 장치를 찾아 마운트하고, pivot_root 또는 switch_root로 전환 PID 1 실행: 커널이 /sbin/init (systemd, SysVinit 등)을 첫 사용자 공간 프로세스로 실행systemd 초기화 (PID 1)systemd는 현대 Linux의 표준 init 시스템이다:default.target├── multi-user.target│ ├── network.target│ │ └── NetworkManager.service│ ├── sshd.service│ ├── docker.service│ └── ...└── graphical.target (데스크톱인 경우) └── display-manager.servicesystemd 부팅 특성: 병렬 시작: 유닛 간 의존성 그래프를 분석해 독립적인 서비스를 동시에 시작. SysVinit의 순차 시작 대비 부팅 시간 단축 소켓 활성화(Socket Activation): 소켓을 먼저 생성하고, 실제 접속이 오면 서비스를 시작. 의존성 순서 문제 해결 타겟(Target): SysVinit의 런레벨(runlevel) 대신 target 유닛으로 부팅 목표를 정의 cgroup 통합: 각 서비스를 독립된 cgroup에 배치해 리소스 추적/제한부팅 분석: systemd-analyze blame (서비스별 시작 시간), systemd-analyze critical-chain (크리티컬 경로), systemd-analyze plot > boot.svg (시각화)
- CS - Network CS cs network CS 단권화 문서입니다.(updated on 2026-03-06) CS 단권화 문서입니다.(updated on 2026-03-06)1. Network FundamentalsOSI 7 Layerflowchart TB subgraph OSI["OSI 7 Layers(계층)"] L7["7 Application(응용)"] L6["6 Presentation(표현)"] L5["5 Session(세션)"] L4["4 Transport(전송)"] L3["3 Network(네트워크)"] L2["2 Data Link(데이터링크)"] L1["1 Physical(물리)"] end subgraph TCPIP["TCP/IP 4 Layers(계층)"] A["Application(응용)"] T["Transport(전송)"] I["Internet(인터넷)"] K["Link(링크)"] end L7 --> A L6 --> A L5 --> A L4 --> T L3 --> I L2 --> K L1 --> KOSI 7계층은 네트워크 통신 기능을 계층별로 분리한 참조 모델이다. 물리 계층부터 응용 계층까지 책임을 나누어 설계하면, 각 계층이 독립적으로 발전하고 문제를 분리해 진단하기 쉬워진다. 실무에서 모든 장비가 OSI를 그대로 구현하는 것은 아니지만, 장애 분석 프레임으로 매우 유용하다.계층은 아래에서 위로 Physical, Data Link, Network, Transport, Session, Presentation, Application 순서다. 예를 들어 웹 요청이 실패했을 때 “DNS 해석 문제(응용/네트워크)”, “TCP 연결 실패(전송)”, “링크 다운(물리/데이터링크)”처럼 원인 층위를 구분할 수 있다.각 계층의 역할과 대표 프로토콜: L1 Physical: 비트 전송. 전기 신호, 광 신호, 주파수(RF). 케이블 규격(Cat5e/6/7), 커넥터(RJ-45, SFP+), 인코딩(NRZ, Manchester, 8b/10b) L2 Data Link: 프레임 전달. MAC 주소 기반 로컬 전송. 오류 검출(CRC/FCS). Ethernet, Wi-Fi(802.11), PPP L3 Network: 패킷 라우팅. 논리 주소(IP) 기반 경로 선택. IP, ICMP, ARP, OSPF, BGP L4 Transport: 종단 간 데이터 전송. 포트 기반 다중화. TCP, UDP, QUIC, SCTP L5 Session: 세션 관리. 연결 설정/유지/종료. TLS 세션, RPC 세션 (실무에서 L7에 통합되는 경우가 많음) L6 Presentation: 데이터 표현. 인코딩/디코딩, 암호화/복호화, 압축. SSL/TLS, JPEG, ASCII/UTF-8 (실제로 L7과 구분이 모호) L7 Application: 사용자 서비스. HTTP, FTP, SMTP, DNS, MQTT, gRPC계층 간 독립성이 핵심이다. 예를 들어 TCP(L4)는 아래 계층이 Ethernet이든 Wi-Fi이든 PPP이든 동일하게 동작한다. 이 추상화 덕분에 각 계층의 기술이 독립적으로 발전할 수 있다.TCP/IP 4 Layerflowchart TB App[Application 계층] Tr[Transport 계층] In[Internet 계층] Li[Link 계층] App --> Tr --> In --> Li Li --> In --> Tr --> AppTCP/IP 모델은 인터넷 실전 구현 중심으로 단순화된 4계층(링크, 인터넷, 전송, 응용) 구조다. 운영체제 네트워크 스택과 실제 프로토콜은 대부분 TCP/IP 모델에 맞춰 설명된다. OSI는 이론적 분해, TCP/IP는 실전 표준에 가깝다고 보면 된다. TCP/IP 계층 OSI 대응 주요 프로토콜 데이터 단위 Application L5~L7 HTTP, DNS, SMTP, SSH Message Transport L4 TCP, UDP Segment/Datagram Internet L3 IP, ICMP, ARP Packet Link L1~L2 Ethernet, Wi-Fi, PPP Frame OSI와 TCP/IP의 실질적 차이: OSI L5(Session)과 L6(Presentation)은 TCP/IP에서 Application 계층으로 통합된다. 실무에서 세션 관리(TLS 세션, HTTP keep-alive)와 데이터 표현(JSON 직렬화, gzip 압축)은 애플리케이션 프로토콜 내에서 처리되는 경우가 대부분이다.Linux 커널 네트워크 스택은 TCP/IP 모델을 따른다: NIC 드라이버(Link) → IP 라우팅/포워딩(Internet) → TCP/UDP 소켓(Transport) → 사용자 공간 애플리케이션(Application). netfilter/iptables는 각 계층에 훅(hook)을 걸어 패킹 필터링/변환을 수행한다.Encapsulationflowchart LR AD["Application Data(응용 데이터)"] --> T["TCP Segment + TCP Header"] T --> I["IP Packet + IP Header"] I --> E["Ethernet Frame + MAC Header / FCS"] E --> W["전송 매체(wire)로 전송"] W --> E2["Frame(프레임)"] E2 --> I2["Packet(패킷)"] I2 --> T2["Segment(세그먼트)"] T2 --> AD2["Application Data(응용 데이터)"]캡슐화는 상위 계층 데이터가 하위 계층으로 내려갈 때 헤더(필요 시 트레일러)를 덧붙이는 과정이다. 반대로 수신 측에서는 디캡슐화로 헤더를 벗겨 원본 데이터를 복원한다. 이때 각 헤더에는 라우팅, 포트 식별, 오류 검출 등 계층별 제어 정보가 담긴다.Application Data (예: HTTP 요청 본문) → [TCP Header(20B) + Data] = Segment → [IP Header(20B) + Segment] = Packet → [Ethernet Header(14B) + Packet + FCS(4B)] = Frame각 헤더의 핵심 정보: TCP Header (20~60B): 출발지/목적지 포트, 시퀀스 번호, ACK 번호, 플래그(SYN/ACK/FIN/RST), 윈도우 크기, 체크섬, 옵션(MSS, 윈도우 스케일링, SACK, 타임스탬프) IP Header (20~60B): 버전, 헤더 길이, ToS/DSCP(QoS), 패킷 전체 길이, 식별자/플래그/오프셋(단편화), TTL, 프로토콜 번호, 헤더 체크섬, 출발지/목적지 IP Ethernet Header (14B): 목적지 MAC(6B), 출발지 MAC(6B), EtherType(2B, 예: 0x0800=IPv4, 0x0806=ARP, 0x86DD=IPv6)최종 프레임 크기: HTTP 데이터 100B → Segment 120B → Packet 140B → Frame 158B. 프로토콜 오버헤드가 약 58B 추가된다. 이 오버헤드 비율은 작은 패킷(예: ACK-only)에서 특히 비효율적으로 작용한다.캡슐화 개념을 이해하면 패킷 캡처(Wireshark) 분석이 쉬워진다. 어느 계층에서 정보가 손상되거나 누락됐는지 정확히 짚을 수 있기 때문이다.MTUflowchart LR A[송신 Packet] --> B{Packet 크기 <= Path MTU 인가?} B -->|Yes| C[바로 전송] B -->|No| D{DF bit set 여부?} D -->|No| E[Packet 분할] D -->|Yes| F["ICMP Fragmentation Needed(단편화 필요)"] F --> G["MSS 감소 / PMTUD 갱신"] E --> C G --> CMTU(Maximum Transmission Unit)는 한 번에 전송 가능한 최대 프레임 payload 크기다. Ethernet 기본 MTU는 보통 1500바이트다. 패킷이 MTU보다 크면 분할(fragmentation)되거나 PMTUD(Path MTU Discovery)로 더 작은 크기에 맞춰 전송한다.MTU 불일치가 있으면 성능 저하 또는 연결 장애(특히 VPN/터널 환경)로 이어질 수 있다. 실무에서는 ping의 DF(Don’t Fragment) 옵션으로 경로 MTU를 추정해 문제를 진단한다.MTU와 관련된 주요 크기값: Ethernet MTU: 1500B (payload) MSS (Maximum Segment Size): MTU - IP Header(20B) - TCP Header(20B) = 1460B. TCP 핸드셰이크 시 양측이 MSS를 교환해 최소값에 맞춘다 Jumbo Frame: MTU 9000B. 데이터센터 내부 통신에서 사용하며, 대용량 전송 효율을 높이고 CPU 인터럽트 횟수를 줄인다. 그러나 경로상 모든 장비가 지원해야 한다IP 단편화 문제: 경로 중간에 MTU가 작은 구간이 있으면 패킷이 분할된다. 분할된 조각 중 하나라도 손실되면 전체 패킷을 재전송해야 하므로 효율이 떨어진다. IPv6에서는 중간 라우터의 단편화를 금지하고, 출발지에서만 단편화를 허용한다(Path MTU Discovery 의무화).PMTUD: 출발지가 DF(Don’t Fragment) 플래그를 설정한 패킷을 전송하고, 경로상 MTU 초과 시 ICMP “Fragmentation Needed” 메시지를 받아 MTU를 줄여 재전송한다. 단, 방화벽이 ICMP를 차단하면 PMTUD가 실패해 “블랙홀” 현상(패킷이 사라짐)이 발생한다. 이 경우 TCP MSS clamping으로 우회한다.2. Physical & Data Link LayerEthernetflowchart LR P["Preamble+SFD"] --> H["Dst MAC | Src MAC | EtherType"] H --> PL["Payload(페이로드) 46~1500B"] PL --> F[FCS CRC32]Ethernet은 LAN에서 가장 널리 쓰이는 데이터 링크 기술이다. 프레임 기반 통신을 하며 목적지/출발지 MAC 주소를 포함한다. 과거 허브 기반 충돌 도메인 환경에서 CSMA/CD가 중요했지만, 현재는 스위치 기반 full duplex가 일반적이라 충돌 개념의 비중이 줄었다.Ethernet 프레임 구조 상세:Preamble(7B) | SFD(1B) | Dst MAC(6B) | Src MAC(6B) | EtherType(2B) | Payload(46~1500B) | FCS(4B) Preamble: 클록 동기화를 위한 10101010 반복 패턴 SFD(Start Frame Delimiter): 10101011. 프레임 시작 표시 EtherType: 상위 프로토콜 식별 (0x0800=IPv4, 0x86DD=IPv6, 0x0806=ARP, 0x8100=802.1Q VLAN) FCS(Frame Check Sequence): CRC-32 기반 오류 검출. 수신 측이 계산한 CRC와 불일치하면 프레임 폐기 (재전송은 상위 계층 책임) 최소 프레임 크기: 64B (헤더+페이로드+FCS). 페이로드가 46B 미만이면 패딩 추가. 이는 CSMA/CD 충돌 감지를 위한 최소 전송 시간 확보 목적Ethernet 속도 발전: 10Mbps(1983) → 100Mbps Fast Ethernet(1995) → 1Gbps GbE(1999) → 10Gbps(2002) → 25/40/100Gbps(2010s) → 400Gbps(2020s). 물리 매체도 동축(10BASE5) → UTP(100BASE-TX) → 광섬유(10GBASE-SR/LR)로 진화했다.MAC Addressflowchart TD MAC["48-bit MAC Address"] --> OUI["상위 24-bit OUI"] MAC --> NIC["하위 24-bit NIC 고유 부분"] MAC --> B0["I/G bit: unicast vs multicast(유니캐스트/멀티캐스트)"] MAC --> B1["U/L bit: global vs local(전역/로컬)"]MAC 주소는 네트워크 인터페이스 카드(NIC)에 할당된 48비트 식별자다. 같은 브로드캐스트 도메인 내에서 프레임 전달 대상을 식별하는 데 사용된다. IP가 논리 주소라면 MAC은 링크 로컬 물리 주소라고 볼 수 있다.일반적으로 앞 24비트는 제조사 OUI, 뒤 24비트는 장치 고유값이다. 가상화/컨테이너 환경에서는 소프트웨어적으로 생성된 MAC도 광범위하게 사용된다.MAC 주소 구조 (예: 00:1A:2B:3C:4D:5E): 비트 0 (I/G bit): 0=유니캐스트, 1=멀티캐스트. 브로드캐스트 주소 FF:FF:FF:FF:FF:FF는 모든 비트가 1이므로 멀티캐스트의 특수 형태 비트 1 (U/L bit): 0=전역 유니크(OUI 기반), 1=로컬 관리(소프트웨어 할당). 가상화 환경에서 생성되는 MAC은 이 비트가 1MAC 주소와 프라이버시: 고정 MAC은 사용자 추적에 악용될 수 있다. 이에 대응해 iOS/Android는 Wi-Fi 접속 시 랜덤 MAC(MAC randomization)을 사용한다. 이는 네트워크 관리(DHCP 예약, 접근 제어)에 복잡성을 추가한다.ARPsequenceDiagram participant A as Host A(호스트 A) participant LAN as LAN Broadcast(브로드캐스트) participant B as Host B(호스트 B) A->>LAN: ARP Request<br/>Who has 192.168.1.20? LAN->>B: 브로드캐스트 요청 전달 B-->>A: ARP Reply<br/>192.168.1.20의 MAC은 00:11:22:33:44:55 A->>A: ARP 캐시 갱신 A->>B: unicast frame 전송ARP(Address Resolution Protocol)는 “같은 네트워크에서 IP → MAC” 매핑을 알아내는 프로토콜이다. 송신자는 ARP Request를 브로드캐스트로 뿌리고, 대상 IP를 가진 호스트가 ARP Reply로 MAC을 반환한다.ARP 동작 상세: 호스트 A가 호스트 B(같은 서브넷)로 패킷을 보내려 한다 A는 ARP 캐시에서 B의 MAC을 조회한다. 없으면: ARP Request 브로드캐스트: “IP 192.168.1.5의 MAC은?” (Dst MAC=FF:FF:FF:FF:FF:FF) 같은 브로드캐스트 도메인의 모든 호스트가 수신하지만, B만 응답 B가 ARP Reply 유니캐스트: “내 MAC은 00:1A:2B:3C:4D:5E” (직접 A에게) A가 ARP 캐시에 (IP → MAC) 엔트리를 저장 (일정 시간 유지, Linux 기본 60초)Gratuitous ARP: 자기 자신의 IP에 대해 ARP Request를 보내는 것. 목적은 ① IP 충돌 감지 ② ARP 캐시 갱신 알림 (IP 변경/failover 시). VRRP/HSRP 같은 게이트웨이 이중화에서 가상 IP 이전 시 필수적으로 사용된다.Proxy ARP: 라우터가 다른 서브넷에 있는 호스트 대신 ARP에 응답하는 기법. 서브넷 구성 없이 다른 네트워크의 호스트와 통신할 수 있게 하지만, 보안/관리 복잡성 때문에 권장되지 않는다.ARP 캐시는 일정 시간 유지되며, 만료되면 다시 질의한다. ARP 스푸핑 같은 공격 벡터가 존재하므로 보안 민감 구간에서는 동적 ARP 검사(DAI), 정적 ARP, 네트워크 분리 정책이 중요하다. ARP 스푸핑은 공격자가 위조된 ARP Reply를 보내 트래픽을 가로채는 중간자 공격(MITM)의 기초 기법이다.Switchflowchart TD F[Frame 도착] --> L["src MAC -> ingress port 학습"] L --> Q{dst MAC가 CAM table에 있나?} Q -->|Yes| U[매핑된 port로 unicast 전달] Q -->|No| M[ingress 제외 전체로 flood]스위치는 데이터 링크 계층 장비로, MAC 주소 테이블(CAM table)을 학습해 프레임을 필요한 포트로만 전달한다. 허브 대비 불필요한 트래픽을 줄이고 충돌 도메인을 분리해 성능을 높인다.MAC 학습 과정: 프레임 수신 시 출발지 MAC과 수신 포트를 CAM 테이블에 기록 목적지 MAC이 테이블에 있으면 해당 포트로만 전송(unicast forwarding) 테이블에 없으면 수신 포트를 제외한 모든 포트로 전송(flooding) 대상 호스트가 응답하면 그때 MAC이 학습됨스위치 전달 방식: Store-and-Forward: 프레임 전체를 수신 후 FCS 검증 후 전달. 오류 프레임을 걸러내지만 지연이 상대적으로 큼 Cut-Through: 목적지 MAC(첫 6B)만 읽고 바로 전달 시작. 지연이 짧지만 오류 프레임도 통과 Fragment-Free: 첫 64B(최소 프레임 크기)까지만 확인 후 전달. 절충안스위치 루프가 생기면 브로드캐스트 폭주가 발생할 수 있으므로 STP(Spanning Tree Protocol) 계열로 루프를 제어한다. STP는 루프가 있는 토폴로지에서 일부 포트를 블로킹 상태로 두어 논리적 트리 구조를 만든다. 수렴 시간이 30~50초로 긴 것이 단점이다. RSTP(Rapid STP)는 수렴 시간을 수 초로 단축하고, MSTP(Multiple STP)는 VLAN별로 독립적인 스패닝 트리를 구성해 대역폭 활용률을 높인다.VLANflowchart LR H1[호스트 A VLAN 10] --> SW1[스위치 1] H2[호스트 B VLAN 20] --> SW1 SW1 -- trunk 802.1Q 태그 --> SW2[스위치 2] SW2 --> S1[서버 VLAN 10] SW2 --> S2[서버 VLAN 20]VLAN은 하나의 물리 스위치를 여러 논리 브로드캐스트 도메인으로 분리하는 기술이다. 부서/서비스별 네트워크 격리, 보안 경계 설정, 브로드캐스트 억제에 효과적이다.트렁크 링크에서는 802.1Q 태그로 VLAN ID를 전달한다. 인터-VLAN 통신은 L3 스위치/라우터가 필요하며, ACL로 통신 정책을 세밀하게 제어한다.802.1Q 태그 구조 (4B가 Ethernet 헤더에 삽입):TPID(2B, 0x8100) | PCP(3bit) | DEI(1bit) | VID(12bit) TPID: 태그 프로토콜 식별자. 0x8100이면 VLAN 태그 존재 PCP(Priority Code Point): QoS 우선순위 (0~7). 음성/영상 트래픽 우선처리에 활용 VID(VLAN ID): 0~4095. 0과 4095는 예약. 실제 사용 가능 범위 1~4094VLAN 운용 실무: Access 포트: 단일 VLAN에 속한 엔드 호스트 연결. 태그 없이 전송 Trunk 포트: 스위치 간 연결. 모든 VLAN 트래픽이 태그와 함께 전달 Native VLAN: 트렁크에서 태그 없이 전달되는 VLAN. 보안 이슈(VLAN hopping)가 있으므로 사용하지 않는 VLAN으로 설정하는 것이 권장됨대규모 데이터센터에서는 VLAN 4094개 제한이 문제가 된다. 이를 해결하기 위해 VXLAN(Virtual Extensible LAN)이 등장했다. VXLAN은 L2 프레임을 UDP로 터널링하며 24비트 VNI(VLAN Network Identifier)로 약 1600만 개의 논리 네트워크를 지원한다.3. Network LayerIPflowchart TD PKT[IP Packet] --> TTL["각 router에서 TTL - 1"] TTL --> DROP{TTL == 0 인가?} DROP -->|Yes| ICMP["ICMP Time Exceeded(시간 초과)"] DROP -->|No| LPM[Longest Prefix Match] LPM --> NH["Next hop/interface 선택"] NH --> FWD[Packet 전달]IP는 비연결형(best-effort) 패킷 전달 프로토콜이다. 송신지에서 수신지까지 패킷이 반드시 도달하거나 순서가 보장되진 않는다. 이런 특성 위에서 전송 계층(TCP)이 신뢰성을 보완한다.IP 헤더 주요 필드 (IPv4, 20B 기본): Version(4bit): 4=IPv4, 6=IPv6 IHL(4bit): 헤더 길이 (32bit 워드 단위) ToS/DSCP(8bit): 서비스 품질 분류. DSCP 코드포인트(6bit)로 QoS 정책 적용 Total Length(16bit): 패킷 전체 길이. 최대 65535B TTL(8bit): 라우터를 거칠 때마다 1 감소. 0이 되면 패킷 폐기 + ICMP Time Exceeded 반환. 라우팅 루프 방지 목적. traceroute는 TTL을 1부터 증가시키며 ICMP 응답으로 경로를 추적한다 Protocol(8bit): 상위 프로토콜 식별 (6=TCP, 17=UDP, 1=ICMP, 47=GRE, 50=ESP) Header Checksum(16bit): 헤더 무결성만 검증 (페이로드는 상위 계층 책임) Source/Destination IP(32bit each): 출발지/목적지 논리 주소라우터는 목적지 IP의 네트워크 부분을 라우팅 테이블과 비교해 최장 접두사 매칭(Longest Prefix Match)으로 다음 홉을 결정한다.IPv4 vs IPv6flowchart LR V4["IPv4 32-bit"] V6["IPv6 128-bit"] V4 --> N1[NAT이 흔히 필요] V4 --> B1[Broadcast 존재] V6 --> N2[매우 큰 주소 공간] V6 --> B2["broadcast 없음, multicast 사용"]IPv4는 32비트 주소 체계로 주소 고갈 문제가 오래전부터 제기됐다. IPv6는 128비트로 사실상 매우 큰 주소 공간을 제공하며, 자동 설정(SLAAC), 확장 헤더, 단순화된 기본 헤더 구조를 제공한다. 비교 IPv4 IPv6 주소 크기 32bit (~43억) 128bit (~3.4×10³⁸) 헤더 크기 20~60B (가변) 40B (고정) + 확장 헤더 단편화 라우터도 가능 출발지만 가능 브로드캐스트 지원 없음 (멀티캐스트로 대체) 주소 자동 설정 DHCP SLAAC + DHCPv6 체크섬 헤더 체크섬 있음 없음 (L2/L4에 위임) NAT 필요성 일반적 불필요 (충분한 주소) IPv6 주소 유형: Link-Local (fe80::/10): 같은 링크 내에서만 유효. 자동 생성. 라우터 디스커버리에 필수 Global Unicast (2000::/3): 인터넷 라우팅 가능한 전역 주소 Unique Local (fc00::/7): IPv4의 사설 주소에 해당 Multicast (ff00::/8): 그룹 통신. ff02::1=모든 노드, ff02::2=모든 라우터전환 기술: 듀얼스택(두 프로토콜 동시 운용), 터널링(IPv6 패킷을 IPv4로 감싸 전달, 6in4/6to4/ISATAP), NAT64/DNS64(IPv6 전용 호스트가 IPv4 서비스에 접근).Subnettingflowchart TD A["192.168.10.0/24"] --> B1["192.168.10.0/26"] A --> B2["192.168.10.64/26"] A --> B3["192.168.10.128/26"] A --> B4["192.168.10.192/26"]서브네팅은 하나의 IP 주소 공간을 여러 작은 네트워크로 나누는 기법이다. 네트워크/호스트 비트 분리(CIDR)를 통해 주소 효율과 라우팅 관리 효율을 높인다.CIDR(Classless Inter-Domain Routing) 계산:192.168.10.0/24 → 서브넷 마스크: 255.255.255.0 네트워크 주소: 192.168.10.0 브로드캐스트: 192.168.10.255 사용 가능 호스트: 192.168.10.1 ~ 192.168.10.254 (254개)/26으로 서브네팅 → 4개 서브넷: 192.168.10.0/26 (호스트: .1~.62, 62개) 192.168.10.64/26 (호스트: .65~.126) 192.168.10.128/26 (호스트: .129~.190) 192.168.10.192/26 (호스트: .193~.254)과거 Classful 주소 체계(A/B/C 클래스)는 비효율적이었다. Class B는 65534개 호스트를 제공하는데, 대부분의 조직은 그보다 적게 필요해 주소 낭비가 심했다. CIDR은 임의의 접두사 길이를 허용해 이 문제를 해결했다.VLSM(Variable Length Subnet Masking)은 서브넷마다 다른 마스크 길이를 적용하는 기법이다. 예를 들어 서버 팜에는 /24(254호스트), 관리 네트워크에는 /28(14호스트), 포인트-투-포인트 링크에는 /30(2호스트)을 할당해 주소 효율을 극대화한다.Routingflowchart TD P[들어온 IP Packet] --> L{Longest Prefix Match} L --> N[다음 hop 선택] N --> T["TTL - 1"] T --> Z{TTL > 0 인가?} Z -->|No| I["ICMP Time Exceeded(시간 초과)"] Z -->|Yes| Q["ARP/ND로 next-hop MAC 확인"] Q --> O[출력 interface로 전달]라우팅은 패킷을 목적지까지 전달하기 위한 경로 선택 과정이다. 정적 라우팅은 단순/예측 가능하지만 확장성에 한계가 있다. 동적 라우팅(OSPF, BGP, IS-IS)은 토폴로지 변화에 자동 대응해 대규모 환경에 적합하다.동적 라우팅 프로토콜 분류: IGP (Interior Gateway Protocol): 단일 AS(자율 시스템) 내부 OSPF: Link-State 알고리즘. 전체 토폴로지 정보를 각 라우터가 보유하고 SPF(Dijkstra) 알고리즘으로 최단 경로 계산. 수렴이 빠르고, 수렴 후에는 일관된 SPF 기반 경로를 형성한다. Area 분할로 확장성 확보 IS-IS: OSPF와 유사한 Link-State. 대규모 ISP 환경에서 선호. IPv4/IPv6 동시 지원이 상대적으로 용이 RIP (Distance Vector): 홉 수 기반. 최대 15홉 제한. 소규모 네트워크 전용. 현재는 거의 사용하지 않음 EGP (Exterior Gateway Protocol): BGP: AS 간 경로 교환. Path Vector 알고리즘. 인터넷 라우팅의 근간. 정책 기반으로 경로 선택(최단 경로가 아닌 비즈니스 관계, 비용, SLA 기반). Full table은 약 100만+ 경로 데이터센터/클라우드에서는 ECMP(동일 비용 다중 경로)와 정책 기반 라우팅을 결합해 고가용성과 처리량을 확보한다. 최근에는 Leaf-Spine 토폴로지에서 BGP를 IGP처럼 사용하는 추세(BGP in the DC)가 확산 중이다.NATsequenceDiagram participant C as Private Host(사설 호스트) participant N as NAT Gateway participant S as Internet Server(인터넷 서버) C->>N: src 10.0.0.5:54321 -> dst 1.2.3.4:443 N->>N: translate to public 203.0.113.10:40001 N->>S: src 203.0.113.10:40001 -> dst 1.2.3.4:443 S-->>N: response to 203.0.113.10:40001 N-->>C: reverse translate to 10.0.0.5:54321NAT(Network Address Translation)는 사설 IP를 공인 IP로 변환해 인터넷 통신을 가능하게 한다. IPv4 주소 부족 대응에 크게 기여했지만, 종단 간 연결성(End-to-End) 단순성을 약화시킨다.NAT 유형: Static NAT: 1:1 고정 매핑. 외부에서 접근 가능한 서버에 사용 Dynamic NAT: 사설 IP를 공인 IP 풀에서 동적 매핑 PAT (Port Address Translation / NAPT): 다수 사설 IP를 하나의 공인 IP + 포트 조합으로 매핑. 가정/사무실 라우터의 기본 방식 내부 192.168.1.10:5000 → 외부 203.0.113.1:40001내부 192.168.1.20:5000 → 외부 203.0.113.1:40002 NAT의 문제점: End-to-End 원칙 위반: 외부에서 내부 호스트로 직접 연결 불가. 서버 호스팅, P2P에 제약 프로토콜 제약: IP 페이로드에 주소를 포함하는 프로토콜(FTP active mode, SIP)은 NAT ALG(Application Level Gateway)가 필요 NAT Traversal: STUN(NAT 유형 파악 + 외부 주소 확인), TURN(릴레이 서버 경유), ICE(STUN/TURN 조합으로 최적 경로 선택). WebRTC 통신에서 필수CGNAT(Carrier-Grade NAT): ISP가 고객에게 사설 IP를 할당하고 대규모 NAT를 운영하는 방식. IPv4 고갈 대응이지만, 이중 NAT로 인한 포트 부족, 지오로케이션 정확도 저하, 로깅 복잡성 등의 문제가 있다.4. Transport LayerTCPstateDiagram-v2 [*] --> CLOSED CLOSED --> LISTEN: 수동 open CLOSED --> SYN_SENT: 능동 open LISTEN --> SYN_RECEIVED: SYN 수신 SYN_SENT --> SYN_RECEIVED: 동시 open SYN_SENT --> ESTABLISHED: SYN+ACK 수신 후 ACK 전송 SYN_RECEIVED --> ESTABLISHED: ACK 수신 ESTABLISHED --> FIN_WAIT_1: 애플리케이션 close ESTABLISHED --> CLOSE_WAIT: FIN 수신 FIN_WAIT_1 --> FIN_WAIT_2: ACK 수신 FIN_WAIT_2 --> TIME_WAIT: FIN 수신 후 ACK 전송 CLOSE_WAIT --> LAST_ACK: close 후 FIN 전송 LAST_ACK --> CLOSED: ACK 수신 TIME_WAIT --> CLOSED: 2MSL 타임아웃sequenceDiagram participant C as Client(클라이언트) participant S as Server(서버) C->>S: SYN seq=x S-->>C: SYN+ACK seq=y ack=x+1 C->>S: ACK ack=y+1 Note over C,S: 연결 수립 C->>S: FIN S-->>C: ACK S-->>C: FIN C->>S: ACK Note over C,S: 클라이언트가 TIME_WAIT 진입TCP는 연결지향, 신뢰성 보장, 순서 보장, 흐름/혼잡 제어를 제공하는 전송 프로토콜이다. 바이트 스트림 기반으로 동작하며 수신 측에서 ACK를 보내고, 송신 측은 재전송/윈도우 조절을 수행한다.3-Way Handshake연결 시작 시 SYN → SYN/ACK → ACK 과정을 거쳐 양쪽의 초기 시퀀스 번호를 동기화한다.상세 과정: Client → Server: SYN (seq=x). 클라이언트가 ISN(Initial Sequence Number)을 생성. ISN은 보안을 위해 예측 불가능한 랜덤/시간 기반 값 Server → Client: SYN+ACK (seq=y, ack=x+1). 서버도 자신의 ISN을 생성하고, 클라이언트의 SYN을 확인 Client → Server: ACK (ack=y+1). 연결 확립. 이 ACK에 데이터를 포함할 수도 있다SYN Flood 공격: 공격자가 대량의 SYN을 보내고 ACK를 보내지 않으면, 서버의 SYN 큐(backlog)가 가득 차 정상 연결이 불가능해진다. 방어책으로 SYN Cookie가 있다: 서버가 SYN 큐에 상태를 저장하지 않고, ISN에 암호학적으로 인코딩된 연결 정보를 넣어 ACK가 돌아왔을 때 복원한다.TCP Fast Open(TFO): 첫 번째 연결에서 발급받은 쿠키를 이후 SYN 패킷에 포함시켜, 핸드셰이크 완료 전에 데이터를 전송할 수 있다. 1-RTT를 절약해 웹 성능을 개선한다.4-Way Termination연결 종료는 일반적으로 FIN → ACK → FIN → ACK 흐름을 거친다. 송신/수신 종료가 독립적이라 4단계가 필요하다(Half-Close 지원).TIME_WAIT 상태: 마지막 ACK를 보낸 후 2×MSL(Maximum Segment Lifetime, 보통 60초) 동안 유지한다. 이유는 ① 지연된 패킷이 새 연결에 혼입되는 것을 방지 ② 상대방이 마지막 ACK를 못 받았을 때 FIN 재전송에 응답하기 위함이다. 서버에 TIME_WAIT 소켓이 대량 누적되면 포트 고갈이 발생할 수 있으며, SO_REUSEADDR, tcp_tw_reuse 설정으로 완화한다.Flow Control흐름 제어는 수신 버퍼 과부하를 막기 위한 메커니즘이다. 수신 측이 광고 윈도우(rwnd)로 처리 가능한 데이터량을 알려주면, 송신 측은 그 범위를 넘지 않게 전송한다.TCP 윈도우 크기는 원래 16비트(최대 65535B)로 제한되었지만, Window Scaling 옵션(RFC 7323)으로 최대 1GB까지 확장 가능하다. 이는 핸드셰이크 시 협상되며, 고대역폭·고지연(BDP가 큰) 경로에서 파이프라인 효율을 극대화한다.Zero Window: 수신 버퍼가 가득 차면 rwnd=0을 광고한다. 송신 측은 전송을 중단하고, 주기적으로 Window Probe 패킷(1B 데이터)을 보내 수신 측의 버퍼 상태를 확인한다. 이 상태가 오래 지속되면 연결 교착이 된다.Congestion Control혼잡 제어는 네트워크 전체 혼잡을 완화하기 위한 정책이다.주요 알고리즘 단계: Slow Start: cwnd를 1 MSS에서 시작해 ACK마다 2배로 증가(지수적). ssthresh(임계치)에 도달하면 Congestion Avoidance로 전환 Congestion Avoidance: cwnd를 RTT당 1 MSS씩 선형 증가 (additive increase) 손실 감지 시: 3 Duplicate ACK (Fast Retransmit): ssthresh = cwnd/2, cwnd = ssthresh + 3 MSS → Fast Recovery 진입 Timeout: ssthresh = cwnd/2, cwnd = 1 MSS → Slow Start 재시작 (더 보수적) Fast Recovery: 중복 ACK 수신 시 cwnd를 1씩 증가. 새 ACK 수신 시 cwnd = ssthresh로 설정하고 Congestion Avoidance로 전환현대 혼잡 제어 알고리즘: CUBIC (Linux 기본): cwnd 증가가 3차 함수를 따른다. 손실 지점 근처에서 보수적으로, 멀어질수록 공격적으로 증가. 고대역폭 환경에 적합 BBR (Google): 손실이 아닌 RTT와 대역폭 측정에 기반. 버퍼블로트 환경에서도 높은 처리량과 낮은 지연을 동시에 추구. YouTube, Google Cloud에서 사용SACK (Selective ACK): 기본 TCP는 누적 ACK만 지원해, 중간 패킷 손실 시 비효율적 재전송이 발생한다. SACK 옵션을 사용하면 수신 측이 “어떤 범위를 받았는지”를 구체적으로 알려줘, 손실 패킷만 정확히 재전송할 수 있다.UDPsequenceDiagram participant C as Client(클라이언트) participant S as Server(서버) C->>S: UDP datagram #1 C->>S: UDP datagram #2 Note over C,S: 핸드셰이크 없음, 내장 재전송/순서 보장 없음 S-->>C: UDP 응답 (선택)UDP는 비연결형, 비신뢰성(재전송/순서보장 없음) 전송 프로토콜이다. 헤더가 작고 지연이 낮아 실시간 미디어, 온라인 게임, DNS 질의 등에 적합하다. 필요한 신뢰성/순서 보장은 애플리케이션 계층에서 직접 구현한다.UDP 헤더 구조 (8B 고정):Source Port(2B) | Destination Port(2B) | Length(2B) | Checksum(2B)TCP 대비 장점: 연결 설정 오버헤드 없음(0-RTT), 헤더 오버헤드 최소(8B vs 20B+), HOL(Head-of-Line) 블로킹 없음, 멀티캐스트/브로드캐스트 지원.UDP 기반 상위 프로토콜: QUIC (HTTP/3 기반): UDP 위에 TLS 1.3 통합, 독립적 스트림 다중화, 0-RTT 연결 재개, 연결 마이그레이션(IP 변경 시 연결 유지)을 구현. TCP의 HOL 블로킹과 핸드셰이크 지연 문제를 해결 DTLS: UDP 위의 TLS. VPN(OpenVPN), WebRTC 데이터 채널에 사용 RTP/RTCP: 실시간 미디어 전송. 시퀀스 번호와 타임스탬프로 순서/타이밍 정보를 제공하지만 재전송은 하지 않음5. Application LayerHTTPsequenceDiagram participant C as Client(클라이언트) participant S as Server(서버) C->>S: GET /index.html HTTP/1.1 S-->>C: 200 OK + headers + body C->>S: GET /api/data S-->>C: 200 OK (JSON)HTTP는 웹의 기본 애플리케이션 프로토콜이다. 요청/응답 모델을 사용하며 메서드(GET/POST/PUT/DELETE), 상태 코드(2xx/4xx/5xx), 헤더 기반으로 동작한다. 본질적으로 무상태(stateless)이므로 세션 상태는 쿠키/토큰/스토리지로 외부화한다.HTTP 메서드의 의미론(Semantics): GET: 리소스 조회. 안전(safe) + 멱등(idempotent). 캐시 가능 POST: 리소스 생성/처리. 비안전 + 비멱등. 서버 상태 변경 PUT: 리소스 전체 교체. 비안전 + 멱등. 같은 요청 반복 시 결과 동일 PATCH: 리소스 부분 수정. 비안전 + 비멱등(구현에 따라 멱등 가능) DELETE: 리소스 삭제. 비안전 + 멱등 HEAD: GET과 동일하지만 응답 본문 없음. 리소스 존재/크기 확인에 사용 OPTIONS: 허용 메서드 조회. CORS preflight에 사용상태 코드 주요 분류: 1xx: 정보성 (100 Continue, 101 Switching Protocols) 2xx: 성공 (200 OK, 201 Created, 204 No Content) 3xx: 리다이렉션 (301 Moved Permanently, 302 Found, 304 Not Modified) 4xx: 클라이언트 오류 (400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 429 Too Many Requests) 5xx: 서버 오류 (500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout)HTTP 1.1 / 2 / 3HTTP/1.1은 텍스트 기반, keep-alive를 통해 연결 재사용이 가능하다. HTTP/2는 바이너리 프레이밍, 멀티플렉싱, 헤더 압축(HPACK)으로 지연을 개선한다. HTTP/3는 TCP 대신 QUIC(UDP 기반)을 사용해 핸드셰이크/손실 복구 지연을 줄인다. 비교 HTTP/1.1 HTTP/2 HTTP/3 전송 TCP TCP QUIC (UDP) 다중화 파이프라이닝(한계) 스트림 멀티플렉싱 독립적 스트림 헤더 텍스트, 중복 전송 HPACK 압축 QPACK 압축 HOL 블로킹 연결 수준 TCP 수준 (L4) 없음 (스트림 독립) 핸드셰이크 TCP 1-RTT + TLS 2-RTT TCP 1-RTT + TLS 1-RTT 1-RTT (0-RTT 가능) Server Push 없음 지원 스펙상 가능하나 브라우저/실무 채택은 제한적 HTTP/2의 핵심 혁신: 하나의 TCP 연결에서 여러 요청/응답을 동시에 처리(멀티플렉싱). HTTP/1.1에서 필요하던 도메인 샤딩(여러 연결 병렬화)이 불필요해졌다. 그러나 TCP 수준의 HOL 블로킹(패킷 손실 시 모든 스트림이 대기)은 여전히 존재한다.HTTP/3의 핵심 혁신: QUIC은 각 스트림이 독립적으로 손실 복구를 수행하므로, 한 스트림의 패킷 손실이 다른 스트림에 영향을 주지 않는다. 또한 연결 ID 기반이므로 Wi-Fi → LTE 전환 시에도 연결이 유지된다(Connection Migration).HTTPSsequenceDiagram participant C as Client(클라이언트) participant S as Server(서버) C->>S: TLS handshake S-->>C: Certificate + Finished C->>S: Encrypted HTTP Request S-->>C: Encrypted HTTP ResponseHTTPS는 HTTP 위에 TLS 암호화 계층을 얹어 기밀성, 무결성, 서버 인증을 제공한다. 현대 웹에서 사실상 기본이며, 브라우저 보안 정책(HSTS, Secure cookie)도 HTTPS를 전제로 강화된다.HTTPS가 제공하는 보안 속성: 기밀성(Confidentiality): 대칭키 암호화(AES-GCM, ChaCha20-Poly1305)로 데이터를 암호화. 도청 방지 무결성(Integrity): MAC(Message Authentication Code)으로 변조 감지 인증(Authentication): X.509 인증서 체인으로 서버 신원 확인. 인증서는 CA(Certificate Authority)가 발급하며, 브라우저/OS의 Root CA 목록과 대조해 신뢰성을 검증HSTS (HTTP Strict Transport Security): 서버가 Strict-Transport-Security 헤더를 보내면, 브라우저는 이후 해당 도메인에 항상 HTTPS로만 접속한다. HTTPS → HTTP 다운그레이드 공격(SSL Stripping) 방지. includeSubDomains, preload 옵션을 함께 사용하는 것이 권장된다.인증서 투명성(Certificate Transparency): CA의 오발급을 감시하기 위해, 모든 인증서를 공개 로그에 기록하도록 요구하는 프레임워크. Chrome은 CT 준수를 강제한다.TLS HandshakesequenceDiagram participant C as Client(클라이언트) participant S as Server(서버) C->>S: ClientHello S-->>C: ServerHello + Certificate + KeyShare C->>S: KeyShare + Finished S-->>C: Finished C->>S: Encrypted Application Data S-->>C: Encrypted Application DataTLS 핸드셰이크는 암호 스위트 협상, 키 교환, 인증서 검증을 수행하는 절차다. TLS 1.3은 핸드셰이크 단계를 줄여 지연을 낮추고 취약한 구식 알고리즘을 제거했다.TLS 1.2 핸드셰이크 (2-RTT): ClientHello: 지원 TLS 버전, 암호 스위트 목록, 클라이언트 랜덤 값 ServerHello: 선택된 암호 스위트, 서버 랜덤 값 + Certificate (서버 인증서) + ServerKeyExchange (DH 파라미터) + ServerHelloDone ClientKeyExchange: DH 공개값 + ChangeCipherSpec + Finished (암호화된 검증 메시지) ChangeCipherSpec + Finished (서버)TLS 1.3 핸드셰이크 (1-RTT): ClientHello: 지원 암호 스위트 + 키 공유 파라미터(DH/ECDH 공개값)를 함께 전송 ServerHello: 선택된 암호 스위트 + 서버 키 공유 + {EncryptedExtensions} + {Certificate} + {CertificateVerify} + {Finished} {Finished} (클라이언트) “1-RTT”만에 암호화 통신 시작. 키 교환과 인증을 동시에 수행 TLS 1.3의 주요 개선: 0-RTT Resumption (Early Data): 이전 세션의 PSK(Pre-Shared Key)로 첫 ClientHello에 데이터를 포함. 반복 방문 시 지연 최소화. 단, replay 공격에 취약하므로 멱등 요청에만 사용 권장 취약 알고리즘 제거: RSA 키 교환(Forward Secrecy 없음), CBC 모드 암호, SHA-1, RC4, 3DES 완전 제거. AEAD(Authenticated Encryption with Associated Data) 알고리즘만 허용 Forward Secrecy 필수: 모든 키 교환이 (EC)DHE 기반. 서버 개인키가 유출되어도 과거 통신은 복호화 불가DNSsequenceDiagram participant U as User Stub Resolver(사용자 Stub Resolver) participant R as Recursive Resolver(재귀 Resolver) participant Root as Root NS participant TLD as TLD NS participant Auth as Authoritative NS(권한 NS) U->>R: query www.example.com A R->>Root: ask .com delegation Root-->>R: .com NS 위임 정보 R->>TLD: ask example.com NS TLD-->>R: authoritative NS 위임 정보 R->>Auth: ask A record Auth-->>R: A = 93.184.216.34 R-->>U: 캐시된 응답 반환DNS는 도메인 이름을 IP 주소로 매핑하는 분산 계층형 시스템이다. 루트 → TLD → 권한(authoritative) 서버 순으로 질의가 진행된다. 캐싱 TTL이 성능과 일관성(전파 지연) 사이의 균형점을 만든다.DNS 질의 과정 상세: 클라이언트가 www.example.com의 IP를 요청 로컬 DNS 리졸버(ISP 또는 8.8.8.8)에 질의 (recursive query) 리졸버의 캐시에 없으면 루트 네임서버 (13개 논리 서버, anycast로 수백 인스턴스)에 질의 루트가 .com TLD 네임서버 주소를 반환 TLD 네임서버에 질의 → example.com의 권한 네임서버(NS 레코드) 반환 권한 네임서버에 질의 → www.example.com의 A 레코드(IP 주소) 반환 리졸버가 결과를 캐싱(TTL 동안)하고 클라이언트에 반환DNS 레코드 유형: A / AAAA: 도메인 → IPv4/IPv6 주소 CNAME: 별칭 → 정규 이름 (예: www.example.com → example.com) MX: 메일 교환 서버 (우선순위 포함) NS: 네임서버 위임 TXT: 임의 텍스트 (SPF, DKIM, 도메인 검증에 활용) SRV: 서비스 위치 (포트, 프로토콜, 가중치 포함) SOA: 존(zone) 기본 정보 (시리얼 번호, 새로고침 간격 등)DNS 보안: DNSSEC: 응답에 디지털 서명을 추가해 위조/변조 방지. 기밀성은 제공하지 않음 DNS over HTTPS (DoH): DNS 질의를 HTTPS로 암호화. 프라이버시 보호, 검열 우회 가능 DNS over TLS (DoT): 전용 포트(853)에서 TLS로 암호화운영에서 DNS 장애는 서비스 전체 장애로 직결된다. 다중 네임서버, 헬스체크 기반 failover, 짧은 TTL 전략을 상황에 맞게 설계해야 한다. TTL이 너무 짧으면 캐시 효율 저하와 네임서버 부하 증가, 너무 길면 변경 사항 전파가 느려져 failover 지연이 발생한다.WebSocketsequenceDiagram participant C as Client(클라이언트) participant S as Server(서버) C->>S: HTTP Upgrade: websocket S-->>C: 101 Switching Protocols C->>S: WebSocket Frame S-->>C: WebSocket FrameWebSocket은 단일 TCP 연결 위에서 양방향 풀-듀플렉스 통신을 제공한다. 실시간 채팅, 협업 편집, 시세 스트리밍 같은 푸시 중심 워크로드에 적합하다.초기에는 HTTP 업그레이드로 시작한 뒤 프레임 기반 지속 연결로 전환된다:GET /chat HTTP/1.1Upgrade: websocketConnection: UpgradeSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==Sec-WebSocket-Version: 13HTTP/1.1 101 Switching ProtocolsUpgrade: websocketConnection: UpgradeSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=WebSocket 프레임 구조: FIN(1bit) + Opcode(4bit, 텍스트/바이너리/close/ping/pong) + MASK(1bit) + Payload Length(7/16/64bit) + Masking Key(4B, 클라이언트→서버만) + Payload.HTTP 폴링/롱폴링 대비 장점: 연결 설정 오버헤드 제거, HTTP 헤더 반복 전송 없음, 진정한 서버 푸시. 단점: HTTP 인프라(캐시, CDN, 프록시)와의 호환성이 제한적, 연결 상태 유지 비용.SSE(Server-Sent Events)와의 비교: SSE는 서버→클라이언트 단방향 스트림만 지원하지만, HTTP 기반이라 인프라 호환성이 좋고 자동 재연결을 내장한다. 알림/피드 같은 서버 푸시 전용 워크로드에서는 SSE가 더 적합할 수 있다.연결 수가 많아질수록 커넥션 관리, 백프레셔, 브로드캐스트 비용 최적화가 중요하다. 대규모 시스템에서는 Redis Pub/Sub이나 Kafka를 백엔드로 사용해 서버 인스턴스 간 메시지를 분산한다.MQTTsequenceDiagram participant Pub as Publisher(발행자) participant Br as Broker(브로커) participant Sub as Subscriber(구독자) Sub->>Br: SUBSCRIBE sensors/temp Pub->>Br: PUBLISH sensors/temp=23 Br-->>Sub: MESSAGE sensors/temp=23MQTT는 경량 pub/sub 프로토콜로 IoT 저전력 환경에 최적화되어 있다. 브로커 중심 토픽 모델과 QoS(0/1/2) 레벨을 제공하며, 불안정 네트워크에서도 비교적 효율적으로 메시지를 전달한다.MQTT QoS 레벨: QoS 0 (At most once): Fire-and-forget. 전달 보장 없음. 가장 빠르고 가볍다. 센서 데이터 주기적 전송에 적합 QoS 1 (At least once): PUBACK 확인. 최소 1회 전달 보장하지만 중복 가능. 중복 처리가 가능한 워크로드에 적합 QoS 2 (Exactly once): 4단계 핸드셰이크(PUBLISH→PUBREC→PUBREL→PUBCOMP). 정확히 1회 전달. 비용이 가장 높지만 결제/명령 같은 정확성 필수 시나리오에 사용MQTT 특수 기능: Retained Message: 브로커가 토픽의 마지막 메시지를 저장해, 새 구독자에게 즉시 전달 Last Will and Testament (LWT): 클라이언트가 비정상 종료되면 브로커가 미리 등록된 “유언” 메시지를 다른 구독자에게 전송. 장치 오프라인 감지에 활용 Clean Session: false로 설정하면 브로커가 구독 정보와 미전달 메시지를 유지. 간헐적 연결 환경에서 메시지 유실 방지MQTT 5.0 주요 추가사항: 공유 구독(여러 클라이언트가 토픽을 분산 처리), 메시지 만료, 사용자 속성, 요청/응답 패턴 지원.6. PerformanceLatency vs Throughputflowchart LR W[워크로드] --> Q[Queue 깊이] Q --> L[지연 시간] Q --> T[처리량] T --> SAT[포화 지점] SAT --> L2[지연 시간이 급격히 증가]Latency는 단일 요청이 완료되기까지 걸리는 시간이고, Throughput은 단위 시간당 처리량이다. 둘은 종종 트레이드오프 관계에 있다. 예를 들어 배치 처리 크기를 늘리면 처리량은 증가할 수 있지만 개별 요청 지연은 늘어날 수 있다.성능 목표를 정의할 때는 평균값보다 p95/p99 지연과 최대 처리량을 함께 봐야 실제 사용자 경험을 반영할 수 있다.BDP(Bandwidth-Delay Product): 대역폭(bps) × RTT(초) = 전송 중인 데이터 양(비트). 이 값이 TCP 윈도우 크기보다 크면 파이프가 완전히 활용되지 않는다. 예: 1Gbps 링크, RTT 100ms → BDP = 12.5MB. 윈도우 크기가 이보다 작으면 대역폭을 다 활용하지 못한다.Little’s Law: $L = \lambda \times W$ (시스템 내 평균 요청 수 = 도착률 × 평균 체류 시간). 네트워크 큐잉, 서버 용량 산정에 유용하다. 예: 평균 응답 500ms, 초당 100 요청이면 동시에 50개 요청이 시스템에 존재한다.지연 분해 분석: 전체 지연 = DNS 해석 + TCP 핸드셰이크(1 RTT) + TLS 핸드셰이크(1~2 RTT) + 요청 전송 + 서버 처리 + 응답 전송. 어느 구간이 지배적인지 파악하면 최적화 포인트가 명확해진다.RTTsequenceDiagram participant C as Client(클라이언트) participant S as Server(서버) C->>S: request at t0 S-->>C: response at t1 Note over C,S: RTT = t1 - t0RTT(Round-Trip Time)는 왕복 지연 시간이다. TCP 핸드셰이크, TLS 협상, API 왕복 비용에 직접적인 영향을 준다. 원거리 리전 간 통신은 RTT가 크므로 캐싱, CDN, 리전 분산 배치가 중요하다.RTT의 구성 요소: 전파 지연(Propagation delay): 빛/전기 신호가 매체를 통과하는 시간. 광섬유 1km ≈ 5μs → 서울-LA(약 9000km) ≈ 90ms 왕복 전송 지연(Transmission delay): 패킷을 링크에 올리는 시간. packet_size / bandwidth 큐잉 지연(Queueing delay): 라우터/스위치 큐에서 대기하는 시간. 혼잡 시 급격히 증가 처리 지연(Processing delay): 라우터의 헤더 검사, 포워딩 결정 시간RTT 최적화 전략: CDN: 콘텐츠를 사용자 근처 엣지 서버에 캐싱. DNS 기반 또는 Anycast 기반으로 가장 가까운 서버로 라우팅 Connection reuse: HTTP keep-alive, 연결 풀링으로 핸드셰이크 RTT 절약 요청 병합: 여러 작은 요청을 하나로 묶어 왕복 횟수 감소 예측적 프리페치: 사용자 행동을 예측해 미리 데이터를 가져옴Packet Lossflowchart TD TX[송신자가 Packet 전송] --> LOSS{Packet 손실?} LOSS -->|No| ACK[ACK 수신] LOSS -->|Yes| DETECT["Timeout / 중복 ACK"] DETECT --> RETX[재전송] RETX --> CWND[혼잡 window 감소]패킷 손실은 재전송을 유발하고 TCP 혼잡 윈도우를 축소시켜 체감 성능을 크게 떨어뜨린다. 무선 환경, 과부하 링크, 큐 버퍼 관리 미흡 시 빈번하다. 손실률과 지연 편차(jitter)를 함께 모니터링해야 원인을 정확히 찾을 수 있다.패킷 손실의 영향 (TCP): 1% 손실: 처리량이 이론 최대의 약 30% 수준으로 하락 (CUBIC 기준, BDP에 따라 다름) 5% 손실: 대부분의 TCP 기반 서비스가 체감적으로 매우 느려짐 손실 시 cwnd가 절반으로 축소되고 복구에 여러 RTT가 필요하므로 고지연 환경에서 영향이 더 크다버퍼 관리 정책: Tail Drop: 큐가 가득 차면 새 패킷을 드롭. 단순하지만 글로벌 동기화(여러 TCP 플로우가 동시에 cwnd 축소) 유발 RED (Random Early Detection): 큐가 임계치에 도달하기 전에 확률적으로 패킷을 드롭. 글로벌 동기화를 완화하고 큐 지연을 낮춤 CoDel / FQ-CoDel: 큐 체류 시간 기반 드롭. 버퍼블로트(과도한 큐잉으로 지연 증가) 해결에 효과적Bandwidthflowchart LR PIPE["Link capacity (Mbps/Gbps)"] --> BDP["Bandwidth-Delay Product"] BDP --> WIN[필요한 send window 크기] WIN --> UTIL[Link 활용률]대역폭은 이론적 최대 전송 용량이다. 하지만 실제 성능은 RTT, 손실률, 프로토콜 오버헤드, 종단 CPU 처리 능력에 의해 제한된다. “대역폭이 충분한데 느리다”는 상황은 대부분 지연/손실/애플리케이션 병목 문제다.Shannon’s Theorem: $C = B \cdot \log_2(1 + \frac{S}{N})$. 채널 용량(C)은 대역폭(B)과 SNR(신호 대 잡음비)에 의해 결정된다. 대역폭을 늘려도 잡음이 크면 한계가 있다.대역폭 vs Goodput: 대역폭은 물리적 최대치이고, Goodput은 애플리케이션이 실제로 사용할 수 있는 유효 처리량이다. 프로토콜 헤더, 재전송, 제어 패킷, 암호화 오버헤드 등을 빼면 goodput은 대역폭의 70~95% 수준이 일반적이다.7. Network ProgrammingSocketflowchart TD A["socket()"] --> B["bind()"] B --> C["listen()"] C --> D["accept()"] D --> E["recv()/send()"] E --> F["close()"]소켓은 애플리케이션이 네트워크 통신을 수행하는 OS 추상화다. 서버는 보통 socket → bind → listen → accept 순서로 연결을 받고, 클라이언트는 socket → connect로 연결을 맺는다.import socket# TCP 서버with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind(("0.0.0.0", 8080)) s.listen(128) # backlog: SYN 큐 + accept 큐 크기 conn, addr = s.accept() with conn: data = conn.recv(1024) conn.sendall(b"OK\n")소켓 옵션 주요 설정: SO_REUSEADDR: TIME_WAIT 상태인 주소에 바인딩 허용. 서버 재시작 시 필수 TCP_NODELAY: Nagle 알고리즘 비활성화. 작은 패킷을 즉시 전송. 실시간 애플리케이션에서 지연 감소 SO_KEEPALIVE: 유휴 연결에 keepalive probe를 보내 끊어진 연결 감지 SO_RCVBUF / SO_SNDBUF: 수신/송신 버퍼 크기 설정. BDP에 맞게 조정하면 처리량 향상 TCP_CORK: 여러 작은 write를 모아서 한 번에 전송. HTTP 응답 헤더+본문을 하나의 패킷으로 묶을 때 유용listen(backlog): backlog 파라미터는 완료된 연결(accept 큐)의 최대 대기 수를 결정한다. Linux에서는 net.core.somaxconn 시스템 파라미터와 min을 취한다. 트래픽이 많은 서버에서 backlog가 너무 작으면 SYN이 드롭되어 연결 실패가 발생한다.Blocking vs Non-blockingflowchart LR B1["Blocking socket(블로킹 소켓)"] --> B2["read()가 thread를 대기시킴"] N1["Non-blocking socket(논블로킹 소켓)"] --> N2["read()가 EAGAIN 반환"] N2 --> N3["epoll / select로 대기"] N3 --> N4[준비되면 처리]블로킹 I/O는 호출이 완료될 때까지 스레드가 대기한다. 구현은 단순하지만 동시 연결이 많아지면 스레드 자원 사용량이 커진다. 논블로킹 I/O는 이벤트 준비 여부를 확인하며 진행해 대규모 동시성에 유리하다.I/O 모델 비교: 동기 블로킹: 1 연결 = 1 스레드. 10K 연결 = 10K 스레드 → 메모리/스케줄링 오버헤드 폭발 (C10K 문제) 동기 논블로킹: read()가 EAGAIN을 반환하면 나중에 재시도. 단독으로는 비효율적(polling loop) I/O 멀티플렉싱: select/poll/epoll/kqueue로 여러 fd를 감시. 이벤트가 준비된 fd만 처리. 하나의 스레드로 수천~수만 연결 관리 가능 비동기 I/O (AIO): 커널에 I/O를 요청하고 완료 시 콜백/시그널로 통지. Linux에서는 io_uring이 현대적 AIO 구현# 논블로킹 소켓 예시import selectsock.setblocking(False)readable, _, _ = select.select([sock], [], [], timeout=1.0)if readable: data = sock.recv(1024) # 준비된 경우에만 읽기C10K 문제: 10,000개 동시 연결 처리의 한계. 전통적 “스레드 per 연결” 모델에서는 컨텍스트 스위칭, 스택 메모리(기본 8MB/스레드), 스케줄러 부하로 수천 연결 이상에서 성능이 급락한다. 이벤트 기반 아키텍처(Node.js, Nginx, Netty)가 이를 해결했고, 현재는 C10M(천만 연결)까지 논의된다.epoll / kqueueflowchart TD A[FD를 한 번만 등록] --> B[Event loop 대기] B --> C{준비된 event가 있나?} C -->|No| B C -->|Yes| D["readable / writable socket 처리"] D --> E["EAGAIN까지 non-blocking read / write"] E --> Bepoll(Linux), kqueue(BSD/macOS)는 대량 소켓 이벤트를 효율적으로 감시하는 커널 인터페이스다. O(N) 폴링 방식보다 확장성이 훨씬 좋다. 현대 고성능 서버(Nginx, Redis 일부 경로, Netty 등)는 이벤트 루프 기반 구조를 채택한다.select/poll의 한계: select: fd_set 크기가 FD_SETSIZE(보통 1024)로 제한. 매 호출마다 전체 fd 집합을 커널에 복사 poll: fd 수 제한은 없지만 매 호출마다 O(N)으로 전체 대상 스캔epoll의 개선 (Linux):int epfd = epoll_create1(0); // epoll 인스턴스 생성epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev); // fd 등록 (한 번만)int n = epoll_wait(epfd, events, MAX, -1); // 준비된 fd만 반환 O(active) 관심 fd를 커널에 한 번 등록하면, 이후 호출마다 복사가 불필요 반환값은 준비된 fd만 포함하므로, 활성 연결 수에 비례하는 O(active connections) 성능트리거 모드: Level Triggered (LT, 기본): 데이터가 있으면 계속 알림. 안전하지만 불필요한 호출 가능 Edge Triggered (ET): 상태 변화 시에만 알림. 효율적이지만 한 번에 모든 데이터를 읽지 않으면 놓칠 수 있어 논블로킹 + 반복 읽기 필수Reverse Proxyflowchart TD C[클라이언트] --> RP[Reverse Proxy] RP --> TLS[TLS Termination] TLS --> LB["Routing / Load Balance"] LB --> A1[앱 1] LB --> A2[앱 2] LB --> A3[앱 3]리버스 프록시는 클라이언트 앞단에서 요청을 받아 내부 서버로 전달한다. TLS 종료, 캐싱, 압축, 라우팅, 인증 연계 등 게이트웨이 기능을 수행한다. 내부 토폴로지를 외부에 노출하지 않아 보안/운영 유연성이 높아진다.리버스 프록시의 기능: TLS Termination: 암복호화를 프록시에서 처리해 백엔드 서버 부하 감소. 인증서 관리를 중앙화 캐싱: 정적 자원, API 응답을 캐싱해 백엔드 부하 감소 압축: gzip/Brotli 압축을 프록시에서 수행 라우팅: URL 경로, 호스트 헤더 기반으로 서로 다른 백엔드로 분배 Rate Limiting: 클라이언트별 요청 빈도 제한 WAF(Web Application Firewall): SQL Injection, XSS 등 공격 패턴 필터링 요청/응답 변환: 헤더 추가/수정, 리다이렉트, URL 재작성대표 구현체: Nginx (C, 이벤트 기반), HAProxy (C, 고성능 TCP/HTTP), Envoy (C++, 서비스 메시 사이드카), Cloudflare/AWS CloudFront (CDN 통합).포워드 프록시 vs 리버스 프록시: 포워드 프록시는 클라이언트를 대리해 외부 서버에 요청하고(클라이언트 익명화, 콘텐츠 필터링), 리버스 프록시는 서버를 대리해 클라이언트 요청을 받는다(서버 보호, 부하분산).Load Balancerflowchart TD C1[클라이언트 1] --> LB[Load Balancer] C2[클라이언트 2] --> LB C3[클라이언트 3] --> LB LB --> S1["App Server A(서버 A)"] LB --> S2["App Server B(서버 B)"] LB --> S3["App Server C(서버 C)"] LB -. health check .-> S1 LB -. health check .-> S2 LB -. health check .-> S3로드 밸런서는 트래픽을 여러 서버 인스턴스로 분산해 가용성과 확장성을 높인다. 라운드로빈, 최소 연결, 해시 기반 분배 등 알고리즘이 있으며, 헬스체크 실패 노드 제외가 핵심 기능이다.분배 알고리즘: Round Robin: 순서대로 분배. 서버 성능이 균일할 때 적합 Weighted Round Robin: 서버 용량에 따라 가중치 부여 Least Connections: 활성 연결이 가장 적은 서버에 분배. 요청 처리 시간이 다양할 때 효과적 IP Hash / Consistent Hashing: 클라이언트 IP(또는 쿠키)를 해시해 동일 서버에 분배. 세션 어피니티(sticky session) 구현. Consistent Hashing은 서버 추가/제거 시 최소한의 재분배만 발생 Least Response Time: 응답 시간이 가장 짧은 서버 선택L4 LB vs L7 LB: L4 (Transport): TCP/UDP 수준에서 분배. 패킷 내용을 해석하지 않아 빠르고 확장성이 높다. DSR(Direct Server Return) 구성 가능. 용도: 범용 TCP 서비스 L7 (Application): HTTP 헤더, 경로, 쿠키, 메서드를 분석해 정교한 라우팅. URL 기반 분배, A/B 테스트, 카나리 배포에 활용. 용도: 웹 애플리케이션헬스체크 유형: TCP 연결 확인: 포트가 열려있는지만 확인. 가장 간단 HTTP 상태 코드: 특정 경로(예: /health)에 GET 요청 후 200 OK 확인 내용 검증: 응답 본문에 특정 문자열 포함 여부 확인 (깊은 헬스체크)8. Distributed System ConceptsConsistencyflowchart LR W[Node A에 write] --> S[Replication] S --> B[Node B 가시 상태] S --> C[Node C 가시 상태] W --> STRONG["Strong: 즉시 전역 가시성"] W --> EVENT["Eventual: 일시적으로 갈라졌다가 수렴"]분산 시스템의 일관성은 “모든 노드가 동일한 상태를 관찰하는 정도”를 의미한다. 강한 일관성은 읽기 결과 예측 가능성이 높지만 지연/가용성 비용이 커질 수 있다. 최종 일관성은 확장성과 가용성에 유리하지만, 짧은 시간 동안 상태 불일치를 허용한다.일관성 모델 스펙트럼: Linearizability (Strong Consistency): 모든 연산이 실시간 순서를 따르는 것처럼 보인다. “쓰기 후 모든 곳에서 즉시 읽기 가능”. 구현 비용이 가장 높다 (Raft/Paxos 기반 합의) Sequential Consistency: 모든 프로세스가 동일한 연산 순서를 관찰하지만, 실시간 순서와는 다를 수 있다 Causal Consistency: 인과 관계가 있는 연산 간에만 순서를 보장한다. 인과 관계가 없는 연산은 서로 다른 순서로 관찰될 수 있다 Eventual Consistency: 모든 쓰기가 결국 모든 노드에 전파된다. 일시적 불일치는 허용하지만, 수렴은 보장. DynamoDB, Cassandra의 기본 모델. 반 엔트로피(anti-entropy) 프로토콜, 읽기 수리(read repair)로 수렴을 가속PACELC 정리 (CAP 확장): 분할(P) 시 가용성(A)과 일관성(C) 중 선택. 정상(E) 시 지연(L)과 일관성(C) 중 선택. 분할이 없을 때에도 트레이드오프가 존재함을 강조한다. 예: DynamoDB는 PA/EL (분할 시 가용성, 정상 시 낮은 지연 우선), ZooKeeper는 PC/EC (분할 시 일관성, 정상 시에도 일관성 우선).Consensus (Raft)stateDiagram-v2 [*] --> Follower Follower --> Candidate: 선출 타임아웃 Candidate --> Leader: 과반 투표 획득 Candidate --> Follower: 더 높은 term 관찰 Leader --> Follower: 더 높은 term 관찰 Leader --> Leader: AppendEntries heartbeat / log replication합의(Consensus)는 여러 노드가 하나의 값/로그 순서에 동의하는 문제다. Raft는 이해하기 쉬운 리더 기반 합의 알고리즘으로, 로그 복제와 리더 선출을 명확히 분리한다. etcd, Consul 같은 시스템이 Raft 계열 아이디어를 활용한다.Raft의 핵심 메커니즘: 리더 선출: 팔로워가 heartbeat 타임아웃 내에 리더로부터 메시지를 받지 못하면, candidate로 전환해 투표를 요청한다. 과반수 투표를 받으면 리더가 된다. 랜덤화된 선출 타임아웃으로 split vote를 최소화한다 로그 복제: 클라이언트 요청이 리더에 도착하면, 리더가 로그 엔트리를 생성하고 팔로워에 AppendEntries RPC로 전파한다. 과반수가 확인(acknowledge)하면 엔트리를 커밋하고 상태 머신에 적용한다 안전성: 투표 시 후보의 로그가 자신보다 충분히 최신(up-to-date)한 경우에만 표를 주므로, 커밋된 엔트리를 잃지 않는 방향으로 리더가 선출된다Raft의 Term(임기): 논리적 시간 단위. 각 term은 리더 선출로 시작한다. term이 높은 메시지만 유효하므로, 오래된 리더가 뒤늦게 복귀해도 자동으로 비활성화된다.Paxos vs Raft: Paxos(Lamport)가 이론적 원조이지만 구현이 매우 어렵다. Raft는 “이해 가능성”을 설계 목표로 두었고, 리더 선출/로그 복제/안전성을 명확히 분리해 구현과 검증이 용이하다. Multi-Paxos/Raft 모두 실전에서는 리더 기반으로 운용된다.핵심은 과반수(quorum) 승인과 로그 일관성 보장이다. 네트워크 분할 상황에서도 안전성(safety)을 우선시하며, 분할된 소수 쪽은 쓰기를 수행할 수 없다.Leader ElectionstateDiagram-v2 [*] --> Follower Follower --> Candidate: 타임아웃 Candidate --> Leader: 과반 투표 획득 Candidate --> Follower: 더 높은 term 관찰 Leader --> Follower: 분할 또는 더 높은 term리더 선출은 클러스터에서 조정 역할을 담당할 단일 노드를 정하는 절차다. 리더가 장애나 분할로 불능이 되면 새 리더를 빠르게 선출해야 서비스 중단 시간을 줄일 수 있다.리더 선출 방식: Raft/Paxos 기반: 합의 알고리즘의 일부로 리더를 선출. 안전성이 보장됨 ZooKeeper ephemeral node: 리더가 특정 경로에 임시(ephemeral) 노드를 생성. 리더 장애 시 노드 자동 삭제 → 다른 후보가 새 노드를 생성해 리더가 됨 Bully Algorithm: 가장 높은 ID를 가진 노드가 리더. 단순하지만 네트워크 분할에 취약 Lease 기반: 리더가 일정 기간(lease) 동안만 유효한 권한을 획득. lease 만료 전에 갱신하지 않으면 자동 해제선출 타임아웃/하트비트 파라미터가 잘못되면 불필요한 리더 변경(flapping)이 발생해 시스템 전체 안정성이 나빠진다. 절대값은 시스템 RTT와 장애 감지 목표에 따라 달라지며, 보통 heartbeat는 수백 ms~수 초, 선출 타임아웃은 heartbeat보다 충분히 크게(예: 3~10배) 설정해 split vote와 오탐을 줄인다.Split-Brain 방지: 네트워크 분할로 두 파티션이 각각 리더를 선출하면 데이터 불일치가 발생한다. Quorum(과반수) 기반 선출은 하나의 파티션만 과반수를 가질 수 있으므로 split-brain을 방지한다. 추가로 fencing token(단조 증가하는 번호)을 사용해 구 리더의 연산을 거부할 수 있다.Gossip Protocolflowchart LR N1[노드 1] -->|rumor| N4[노드 4] N4 -->|rumor| N2[노드 2] N2 -->|rumor| N5[노드 5] N5 -->|rumor| N3[노드 3] N3 -->|rumor| N6[노드 6]가십 프로토콜은 노드들이 무작위로 상태를 교환하며 정보가 전체로 확산되는 방식이다. 중앙 집중식 디렉터리 없이도 확장성과 장애 내성이 높다. 멤버십 관리, 장애 감지, 분산 캐시 메타데이터 전파에 자주 쓰인다.가십 동작 원리: 각 노드는 주기적으로(예: 1초마다) 무작위로 다른 노드를 선택 선택된 노드와 상태 정보를 교환 (push, pull, 또는 push-pull) 새로운 정보를 받으면 자신의 상태를 갱신정보 확산 속도: N개 노드에서 정보가 전체에 퍼지는 데 $O(\log N)$ 라운드가 필요하다. 1000개 노드에서 약 10라운드(10초)면 전파 완료. 이 로그 확산 성질이 가십의 확장성 핵심이다.장애 감지 (SWIM 프로토콜): 노드 A가 노드 B에 ping B가 응답하지 않으면, A가 무작위 노드 C에 “B에 대리 ping 해달라”(indirect ping)고 요청 C도 B 응답을 못 받으면, A가 B를 “의심(suspect)” 상태로 전환 B가 일정 시간 내 자신의 생존을 증명하지 못하면 “사망(dead)” 처리대표 구현: Consul/Serf: HashiCorp의 멤버십/서비스 디스커버리. SWIM 기반 Cassandra: 가십으로 클러스터 토폴로지, 스키마 변경, 토큰 범위를 전파 Redis Cluster: 노드 상태와 슬롯 할당을 가십으로 공유정확한 즉시 동기화보다 “확률적 빠른 전파”를 목표로 하므로, eventual consistency와 잘 결합된다.Raft Membership Change & Log Compactionflowchart TD C1[현재 config C_old] --> J["Joint config C_old,new"] J --> C2[새 config C_new] C2 --> L[log 증가] L --> S[snapshot 생성] S --> T[오래된 log 엔트리 절단]실제 운영에서 Raft 클러스터의 노드 추가/제거와 로그 비대화 관리는 필수적이다.Membership Change (Configuration Change)클러스터 멤버십 변경 시 가장 위험한 상황은 두 개의 과반수(disjoint majority) 가 동시에 존재하는 것이다. 예를 들어 3노드→5노드 전환 중, 구 설정(3)과 신 설정(5)에서 각각 과반이 성립할 수 있다.Joint Consensus (원래 Raft 논문): 리더가 $C_{old,new}$ (조인트 설정) 로그 엔트리를 생성 $C_{old,new}$ 기간: 결정에 구 설정과 신 설정 모두의 과반이 필요 $C_{old,new}$가 커밋되면, 리더가 $C_{new}$ 엔트리를 생성 $C_{new}$가 커밋되면 전환 완료. 구 설정에만 속한 노드는 자동 은퇴Single-Server Change (실전 권장): 한 번에 노드를 하나씩만 추가/제거. 구현이 단순해지고 안전성 검증이 쉬워서, 많은 시스템이 일반적인 joint consensus 전체 절차 대신 이런 순차 변경 정책을 채택한다 etcd, HashiCorp Raft 등 대부분의 프로덕션 구현이 이 방식 채택 여러 노드 변경 시 순차적으로 한 번씩 수행리더 교체 시나리오: 리더 자신이 제거되는 경우, $C_{new}$ 커밋까지만 리더로 남고 이후 자동 퇴임. 새 설정의 노드들이 새 리더를 선출한다.Log Compaction & SnapshotRaft 로그가 무한 증가하면 디스크 소모와 새 노드 합류 시 전체 replay 비용이 문제된다.Snapshot (스냅샷): 현재 상태 머신의 전체 상태를 특정 시점에 직렬화 스냅샷 이전의 모든 로그 엔트리를 삭제 가능 스냅샷 메타데이터: last included index, last included term, 클러스터 설정 포함스냅샷 전송 (InstallSnapshot RPC): 팔로워가 너무 뒤처져 리더의 로그에 해당 엔트리가 이미 삭제되었으면, 리더가 스냅샷 전체를 전송 대용량 스냅샷은 청크(chunk) 분할 전송 팔로워는 수신 완료 후 로컬 로그를 스냅샷 시점까지 교체스냅샷 전략: 크기 기반: 로그가 N바이트 이상 누적되면 트리거 (etcd 기본: --snapshot-count=100000) 시간 기반: 주기적 스냅샷 Copy-on-Write: fork()로 자식 프로세스에서 스냅샷 생성(Redis RDB와 유사). 메인 스레드 블로킹 최소화CRDT (Conflict-Free Replicated Data Type)flowchart LR N1[Replica A update] --> M["Commutative / associative merge"] N2[Replica B update] --> M M --> C[수렴된 상태] C --> E["각 write마다 중앙 lock / consensus 없음"]CRDT는 동시 수정이 발생해도 별도의 합의 프로토콜 없이 자동으로 수렴하는 자료구조다. Eventual Consistency를 수학적으로 보장한다.CRDT의 두 가지 형태: State-based CRDT (CvRDT): 노드 간 전체 상태를 교환하고 merge 함수로 합침. merge는 교환법칙·결합법칙·멱등법칙을 만족해야 함 (= join-semilattice) Operation-based CRDT (CmRDT): 변경 연산을 브로드캐스트. 연산이 교환법칙을 만족하면 순서 무관하게 동일 결과. 정확히 한 번(exactly-once) 전달이 필요주요 CRDT 타입: CRDT 설명 사용 사례 G-Counter 증가만 가능한 카운터. 노드별 카운터 맵, 최종 합산 분산 페이지뷰 카운트 PN-Counter G-Counter 2개: 증가용 P + 감소용 N. 값 = P - N 좋아요/싫어요, 재고 수량 LWW-Register Last-Writer-Wins. 타임스탬프가 큰 쓰기가 승리 Cassandra 셀 값 OR-Set Observed-Remove Set. 추가/삭제가 동시에 발생해도 안전 태그/집합 관리 LWW-Element-Set 원소별 타임스탬프로 추가/삭제 판정 소셜 친구 목록 Merkle-CRDT Merkle DAG + CRDT. IPFS/OrbitDB에서 사용 분산 데이터베이스 G-Counter 동작 예시:Node A: {A:3, B:0, C:0} → 로컬 값 = 3Node B: {A:0, B:5, C:0} → 로컬 값 = 5Node C: {A:0, B:0, C:2} → 로컬 값 = 2Merge: {A:max(3,0,0), B:max(0,5,0), C:max(0,0,2)} = {A:3, B:5, C:2}전체 카운터 값 = 3 + 5 + 2 = 10CRDT의 한계: 삭제가 어렵다 (tombstone 필요, 메모리 누적) 복잡한 데이터 관계 표현이 어렵다 (예: 외래 키 제약) 의미적 충돌은 자동 해결 불가 (예: 두 사용자가 동시에 문서 제목을 다르게 변경)실전 채택: Redis CRDT (Redis Enterprise), Riak, Automerge (공동 편집), Figma (실시간 디자인 협업), Apple Notes/iCloud (동기화 충돌 해결)Leaderless Replicationflowchart TD W[클라이언트 write] --> N1[Replica A] W --> N2[Replica B] W --> N3[Replica C] N1 --> QW[W quorum 도달] N2 --> QW R[클라이언트 read] --> N1 R --> N2 R --> N3 N1 --> QR[R quorum 병합] N2 --> QR리더리스 복제는 특정 리더 없이 모든 노드가 읽기/쓰기를 직접 처리하는 방식이다. 리더 장애에 의한 failover가 불필요하고 쓰기 가용성이 높다.Quorum 읽기/쓰기N개의 복제본에서: W: 쓰기가 성공으로 간주되려면 확인받아야 하는 노드 수 R: 읽기 시 응답을 받아야 하는 노드 수 $W + R > N$ 이면 최소 하나의 노드가 최신 쓰기를 포함 → 읽기 일관성 보장일반적인 설정: N=3, W=2, R=2 — 하나의 노드 장애를 허용하면서 일관성 유지. 설정 W R 특성 강한 일관성 N 1 모든 노드에 쓰기, 하나에서 읽기 읽기 최적화 1 N 빠른 쓰기, 모든 노드에서 읽기 균형 ⌈(N+1)/2⌉ ⌈(N+1)/2⌉ 읽기/쓰기 균형 Anti-Entropy & Read Repair오래된 복제본을 최신으로 수렴시키는 메커니즘: Read Repair: 읽기 시 여러 노드의 응답을 비교해, 오래된 노드에 최신 값을 비동기로 전파. 자주 읽히는 데이터에 효과적 Anti-Entropy: 백그라운드 프로세스가 주기적으로 Merkle Tree를 비교해 불일치 데이터를 동기화. Cassandra가 이 방식 사용 Hinted Handoff: 대상 노드 장애 시 다른 노드가 임시로 쓰기를 보관(hint)하고, 복구 후 전달. 쓰기 가용성 유지Sloppy Quorum & Hinted Handoff엄격한 Quorum은 N개의 지정된 노드 중 W/R개를 요구하지만, Sloppy Quorum은 원래 노드가 불가하면 다른 노드로 대체해 가용성을 유지한다. 일관성은 약화되지만 쓰기가 거부되지 않는다.Dynamo-style DB 비교: 특성 Cassandra Riak DynamoDB 쿼럼 튜닝 가능 (CL) 튜닝 가능 내부 관리 충돌 해결 LWW (기본) Vector Clock / CRDT LWW Anti-Entropy Merkle Tree AAE (Active Anti-Entropy) 내부 Hinted Handoff 지원 지원 지원 일관성 레벨 ONE/QUORUM/ALL 등 요청 시 지정 Eventually / Strong Vector Clock: 각 노드가 논리적 시계 벡터를 유지해 인과 관계를 추적한다. 벡터 비교로 동시(concurrent) 수정을 정확히 판별하고, 애플리케이션이 충돌을 해결하도록 위임한다. 단, 벡터 크기가 노드 수에 비례해 메타데이터 오버헤드가 커질 수 있어, dotted version vector 등 컴팩트한 변형이 연구되고 있다.
- CS - Database CS cs database CS 단권화 문서입니다.(updated on 2026-03-06) CS 단권화 문서입니다.(updated on 2026-03-06)1. Introduction데이터베이스의 역사flowchart LR A["1960년대<br/>Hierarchical / Network DB"] --> B["1970년대<br/>Relational Model(SQL)"] B --> C["2000년대<br/>웹 규모용 NoSQL"] C --> D["2010년대<br/>NewSQL / 분산 ACID"] D --> E["현재<br/>Polyglot persistence(다중 저장소 전략)"]초기 컴퓨팅 환경에서는 데이터를 파일 단위로 관리하는 방식이 일반적이었다. 하지만 데이터 규모가 커지고 동시 사용자 수가 증가하면서, 중복과 불일치 문제를 체계적으로 해결할 필요가 생겼다.데이터 모델의 진화: 1960s 계층형(Hierarchical) 모델: IBM IMS. 부모-자식 트리 구조. 경로가 고정되어 유연한 질의가 어렵다 1960s 네트워크형(Network) 모델: CODASYL. 그래프 구조로 다대다 관계 표현 가능. 하지만 프로그래머가 물리적 접근 경로를 직접 관리해야 해 복잡하다 1970 관계형(Relational) 모델: Edgar Codd의 논문. 수학적 기반(집합, 관계 대수)으로 물리/논리 독립성 확보. 선언적 질의(SQL)로 생산성 혁신. 이후 Oracle, PostgreSQL, MySQL 등이 주류 2000s NoSQL/분산 DB: 웹/모바일 시대의 대용량 분산 처리 필요. Google Bigtable(2006), Amazon Dynamo(2007) 논문이 촉발. MongoDB, Cassandra, Redis 등 2010s NewSQL: 관계형의 ACID + 분산 확장성. Google Spanner(2012), CockroachDB, TiDB 현재 Polyglot Persistence: RDB와 NoSQL을 목적에 맞게 조합. 캐시(Redis) + 주 DB(PostgreSQL) + 검색(Elasticsearch) + 분석(ClickHouse)File system vs DBMSflowchart LR APP[애플리케이션] --> FS[File API] APP --> DB[DBMS SQL] FS --> F1[수동 locking / parsing] DB --> D1["트랜잭션 + index + optimizer"] DB --> D2["복구 + concurrency control(동시성 제어)"]파일 시스템은 단순 저장에 강점이 있지만, 동시성 제어, 트랜잭션, 인덱싱, 질의 최적화, 복구 같은 고급 기능은 제한적이다. 반면 DBMS는 데이터 일관성과 접근 성능을 체계적으로 보장한다. 비교 항목 파일 시스템 DBMS 동시 접근 파일 락(coarse-grained) 행/페이지 수준 잠금, MVCC 데이터 무결성 애플리케이션이 직접 보장 제약조건, 트랜잭션으로 보장 질의 전체 파일 순차 읽기 인덱스 + 옵티마이저 장애 복구 파일 손상 시 수동 복구 WAL + 자동 crash recovery 중복 제어 중복 발생 시 수동 관리 정규화, 제약조건으로 제어 백업/복제 파일 복사 온라인 백업, 스트리밍 복제 예를 들어 파일 기반 구현에서는 “주문 생성 + 재고 차감” 같은 다중 단계 작업의 원자성을 직접 구현해야 한다(파일 잠금, 임시 파일 쓰기, 원자적 rename 등). DBMS는 BEGIN; INSERT INTO orders ...; UPDATE inventory ...; COMMIT;으로 원자성을 기본 제공한다.그러나 모든 상황에서 DBMS가 최선은 아니다. 로그 수집(append-only), 설정 파일, 정적 데이터는 파일 시스템이 더 단순하고 효율적이다. SQLite는 이 경계에서 “파일 기반 DBMS”로 임베디드 용도에 널리 사용된다.DBMS의 역할flowchart LR APP[애플리케이션 SQL] --> PARSER["Parser / Analyzer(파서 / 분석기)"] PARSER --> OPT[Optimizer] OPT --> EXEC["Executor(실행기)"] EXEC --> BUF["Buffer Manager(버퍼 관리자)"] BUF --> IO["Index/Heap Access"] EXEC --> WAL[WAL Logger] IO --> DISK[(Data Files)]DBMS는 단순 저장소가 아니라 데이터 운영 플랫폼이다. 핵심 역할: 스키마 관리: DDL로 테이블/인덱스/뷰 정의. 스키마 변경(ALTER)의 온라인/오프라인 처리가 운영 이슈 질의 처리/최적화: 파싱 → 의미 분석 → 논리 최적화(규칙 기반 변환) → 물리 최적화(비용 기반 계획 선택) → 실행 동시성 제어: 다중 트랜잭션이 데이터 정합성을 위반하지 않도록 조율 (락, MVCC, OCC) 장애 복구: WAL + 체크포인트로 crash 후 데이터를 일관된 상태로 복원 (ARIES 알고리즘) 보안/권한 관리: 사용자/역할 기반 접근 제어(RBAC). Row Level Security(RLS)로 행 수준 접근 제한 백업/복제: 물리 백업(basebackup), 논리 백업(pg_dump), 증분 백업, 스트리밍 복제, 논리 복제메모리 관리: DBMS는 자체 버퍼 풀을 운영해 OS 페이지 캐시와 별도로 메모리를 관리한다. 이는 OS보다 데이터 접근 패턴을 잘 알기 때문이다. InnoDB의 버퍼 풀, PostgreSQL의 shared_buffers가 대표적.OLTP vs OLAPflowchart TD subgraph OLTP["OLTP"] TP["Transactional DB(트랜잭션 DB)<br/>MySQL · PostgreSQL"] TP_C["짧은 트랜잭션<br/>행 단위 접근<br/>높은 동시성"] end TP --> ETL["CDC / ETL"] ETL --> WH subgraph OLAP["OLAP"] WH["Data Warehouse(데이터 웨어하우스)<br/>ClickHouse · BigQuery"] BI["분석 쿼리 / BI 대시보드"] OLAP_C["대량 집계 스캔<br/>컬럼 지향 저장<br/>스타 스키마"] WH --> BI end 비교 OLTP OLAP 목적 트랜잭션 처리 분석/리포팅 쿼리 패턴 단순, 소량 행 접근 복잡 집계, 대량 스캔 지연 목표 밀리초 수준 초~분 허용 동시성 높은 동시 사용자 소수 분석가/배치 데이터 모델 정규화 (중복 최소) 비정규화/스타 스키마 쓰기 패턴 대량 짧은 트랜잭션 대량 배치 적재 (ETL) 대표 시스템 PostgreSQL, MySQL ClickHouse, Redshift, BigQuery HTAP (Hybrid Transactional/Analytical Processing): OLTP + OLAP을 단일 시스템에서 처리하려는 시도. TiDB, AlloyDB, SingleStoreDB 등이 추구하는 방향. 실시간 분석 요구가 증가하면서 주목받고 있다.스타 스키마 / 스노우플레이크 스키마: OLAP용 데이터 모델링. 중앙의 팩트 테이블(예: 주문)에 디멘전 테이블(사용자, 상품, 시간)이 연결되는 구조. 스타 스키마는 디멘전이 비정규화되어 조인이 적고, 스노우플레이크 스키마는 디멘전이 정규화되어 공간 효율적이다.CAP Theoremgraph TD C[일관성<br/>Consistency] A[가용성<br/>Availability] P[분할 허용성<br/>Partition Tolerance] C ---|"CA: 단일 노드<br/>RDBMS"| A A ---|"AP: Cassandra<br/>DynamoDB"| P P ---|"CP: ZooKeeper<br/>etcd"| C Note["분할 발생 시 CP 또는 AP 중 선택"] P -.-> NoteCAP 정리는 분산 시스템에서 일관성(Consistency), 가용성(Availability), 분할 내성(Partition tolerance)을 동시에 완벽히 만족하기 어렵다는 원칙이다.정확한 정의: Consistency: 모든 노드가 동일 시점에 같은 데이터를 반환 (linearizability) Availability: 장애 없는 노드는 합리적 시간 내에 응답을 반환 Partition Tolerance: 노드 간 네트워크 메시지 손실/지연이 있어도 시스템이 동작네트워크 분할(P)은 분산 시스템에서 불가피하므로, 실질적 선택은 CP vs AP: CP 시스템: 분할 시 일관성 우선. 일부 요청을 거부할 수 있다. 예: ZooKeeper, etcd, HBase AP 시스템: 분할 시 가용성 우선. 일시적 불일치를 허용한다. 예: Cassandra, DynamoDB, CouchDBPACELC 확장: 분할(P) 시 A vs C 선택, 정상(E) 시 Latency vs Consistency 선택. CAP만으로는 정상 상태의 트레이드오프를 표현하지 못하는 한계를 보완한다. 예: DynamoDB는 PA/EL (분할 시 가용성, 정상 시 낮은 지연), Spanner는 PC/EC (분할 시 일관성, 정상 시에도 일관성).2. Data ModelingEntity-Relationship ModelerDiagram CUSTOMER ||--o{ ORDER : places ORDER ||--|{ ORDER_ITEM : contains PRODUCT ||--o{ ORDER_ITEM : included_in CUSTOMER { bigint customer_id PK string name string email } ORDER { bigint order_id PK datetime ordered_at string status } PRODUCT { bigint product_id PK string name decimal price } ORDER_ITEM { bigint order_id FK bigint product_id FK int quantity }ER 모델은 현실 세계의 객체(Entity), 속성(Attribute), 관계(Relationship)를 추상화해 데이터 구조를 설계하는 방법이다.ER 모델 구성 요소: Entity: 독립적으로 식별 가능한 객체. 강 엔티티(자체 PK), 약 엔티티(부분키 + 다른 엔티티에 의존) Attribute: 엔티티의 특성. 단순/복합(주소 = 시+구+동), 단일값/다중값(전화번호), 유도(derived, 나이 = 현재 - 생년) Relationship: 엔티티 간 연관. 차수(1:1, 1:N, M:N), 참여도(필수/선택), 자기참조(직원-상사)설계 시 주의점: 엔티티와 속성의 구분: “주소”가 단순 문자열인지, 별도 엔티티로 관리해야 하는지는 비즈니스 요구에 따라 다르다 N:M 관계는 구현 시 연결(junction) 테이블로 분해한다. 이 테이블에 관계 속성(예: 수강 학점, 참여 역할)을 추가할 수 있다 이력 관리: 현재 값만 저장할 것인지, 변경 이력을 추적할 것인지 설계 초기에 결정해야 한다ERD 설계flowchart LR C[개념 모델<br/>Conceptual Model] --> L[논리 모델<br/>Logical Model] L --> P[물리 모델<br/>Physical Model] C --> E["엔터티 / 관계"] L --> N["키 / 정규화"] P --> I["Index / Partition / Storage"]ERD는 엔티티 간 관계(1:1, 1:N, N:M)와 키 구조를 시각적으로 표현한다.표기법: Chen 표기법: 다이아몬드(관계), 직사각형(엔티티), 타원(속성). 학술적 Crow’s Foot (IE) 표기법: 까마귀 발 모양으로 다수성(cardinality) 표현. 실무에서 가장 널리 사용 UML 클래스 다이어그램: 소프트웨어 설계와 통합 표현 가능설계 반복 과정: 개념 모델(Conceptual): 핵심 엔티티와 관계만 식별. 비즈니스 이해관계자와 소통용 논리 모델(Logical): 속성, 키, 정규화 적용. DBMS 독립적 물리 모델(Physical): 특정 DBMS 타입, 인덱스, 파티션, 테이블스페이스 결정Anti-pattern: “일단 만들고 나중에 수정” → 서비스 성장 후 테이블 구조 변경은 마이그레이션 비용이 매우 크다. 초기 설계에 시간을 투자하는 것이 전체 비용을 줄인다.Normalizationflowchart LR A[비정규형] --> B[1NF<br/>원자 값] B --> C[2NF<br/>부분 함수 종속 없음] C --> D[3NF<br/>이행 함수 종속 없음] D --> E[BCNF<br/>결정자가 candidate key]정규화는 데이터 중복과 갱신 이상(anomaly)을 줄이기 위한 설계 원칙이다. 테이블을 적절히 분해해 데이터 무결성을 높인다.갱신 이상의 종류: 삽입 이상: 불필요한 정보 없이는 데이터를 삽입할 수 없음 (예: 학과 정보를 넣으려면 학생이 있어야 함) 삭제 이상: 정보 삭제 시 의도하지 않은 데이터까지 사라짐 갱신 이상: 중복 데이터 중 일부만 수정되어 불일치 발생1NF, 2NF, 3NF1NF (First Normal Form): 모든 속성이 원자값(atomic value)이어야 한다 반복 그룹, 다중값 속성 제거 위반 예: 전화번호 컬럼에 “010-1234-5678, 010-8765-4321” → 별도 테이블로 분리2NF (Second Normal Form): 1NF + 부분 함수 종속 제거 복합 기본키의 일부에만 종속된 속성을 별도 테이블로 분리 예: (학생ID, 과목ID) → 학생이름. 학생이름은 학생ID만으로 결정 → 학생 테이블로 분리3NF (Third Normal Form): 2NF + 이행 함수 종속 제거 키가 아닌 속성이 다른 키가 아닌 속성을 결정하면 분리 예: 학생ID → 학과코드 → 학과명. 학과코드 → 학과명이 이행 종속 → 학과 테이블로 분리정규화의 실전 판단: 보통 3NF까지를 목표로 하되, 조회 성능이 중요한 경우 의도적으로 비정규화한다. 핵심은 “어디서 중복이 생기는지 알고 있으면서” 비정규화하는 것과, “모르고 중복된 채로 설계하는 것”의 차이다.BCNFBCNF(Boyce-Codd Normal Form)는 3NF보다 더 엄격한 형태로, 모든 결정자(determinant)가 후보키(candidate key)가 되도록 요구한다.3NF vs BCNF의 차이가 나타나는 경우:학생 | 과목 | 교수──────────────────복합키: (학생, 과목)종속: 교수 → 과목 (교수가 한 과목만 강의)3NF는 만족하지만 BCNF는 위반 (교수가 결정자이지만 후보키가 아님). BCNF로 분해: (교수, 과목) + (학생, 교수).BCNF 분해의 트레이드오프: 함수 종속 보존(dependency preservation)이 보장되지 않을 수 있다. 일부 제약을 조인 후에만 검증할 수 있게 되어 실용성이 떨어질 수 있다.Denormalizationflowchart TD N[정규화된 스키마] --> B["읽기 병목이 있나?"] B -->|Yes| D["중복 / 파생 컬럼 추가"] D --> C[일관성 규칙 유지] C --> R[join 비용 감소] B -->|No| K[Keep normalized]비정규화는 읽기 성능 향상을 위해 일부 중복을 의도적으로 허용하는 전략이다.비정규화 기법: 파생 컬럼 추가: 주문 합계를 주문 테이블에 저장 (원래는 주문항목에서 SUM으로 계산). 주문항목 변경 시 합계도 갱신해야 함 테이블 병합(Pre-join): 자주 조인하는 테이블을 하나로 합침. 읽기 빈도 » 쓰기 빈도일 때 효과적 요약 테이블: 집계 결과를 별도 테이블에 저장. 일별/월별 통계를 미리 계산 중복 컬럼 추가: FK 대신 자주 조회하는 값을 직접 저장. 예: 주문에 고객명을 중복 저장비정규화 적용 원칙: 먼저 정규화된 설계에서 성능 병목을 측정한다 병목이 조인 비용이라면 비정규화를 검토한다 중복 데이터의 동기화 전략(트리거, 애플리케이션 로직, 이벤트 기반)을 명확히 정의한다 데이터 불일치 발생 시 영향과 복구 방법을 문서화한다3. Relational ModelRelational Algebraflowchart LR A[Students] --> S1["σ age > 20"] B[Enrollments] --> S2["σ year = 2026"] S1 --> J[⋈ student_id] S2 --> J J --> P["π name, course_id"]관계 대수는 관계형 질의의 이론적 기반이다. SQL 최적화기는 내부적으로 이런 연산 트리 형태로 실행 계획을 구성한다.기본 연산: Selection ($\sigma$): 조건을 만족하는 튜플 선택. $\sigma_{age > 20}(Students)$ → WHERE 절 Projection ($\pi$): 특정 속성만 추출. $\pi_{name, age}(Students)$ → SELECT 절 Cartesian Product ($\times$): 두 관계의 모든 조합. $R \times S$ → FROM R, S (WHERE 없이) Union ($\cup$): 합집합. 스키마가 호환되어야 함. → UNION Difference ($-$): 차집합. → EXCEPT Rename ($\rho$): 관계/속성 이름 변경. → AS유도 연산: Natural Join ($\bowtie$): 공통 속성 기준 조인. → NATURAL JOIN Theta Join ($\bowtie_\theta$): 조건(θ) 기반 조인. → JOIN … ON … Semi Join: 조인 결과에서 한쪽 관계의 튜플만 반환. → EXISTS 서브쿼리 Division: “모든 것과 관련된 튜플” 찾기. 예: “모든 과목을 수강한 학생”쿼리 최적화에서 관계 대수의 동치 변환이 핵심이다. 예: $\sigma_{condition}(R \bowtie S) \equiv \sigma_{condition}(R) \bowtie S$ (selection push-down). 조인 전에 필터링하면 중간 결과 크기가 줄어 성능이 향상된다.Tuple / Domainflowchart TD REL["Relation / Table"] --> TUP["Tuple (row)"] REL --> ATTR["속성 (columns)"] ATTR --> DOM["domain / type 제약"] DOM --> VAL[유효 값 집합]Tuple은 테이블의 한 행(row)으로, 관계형 모델에서 하나의 사실(fact)을 표현한다. Domain은 속성이 가질 수 있는 값의 집합(타입/제약)을 의미한다.Domain 설계의 실전 중요성: 적절한 타입 선택: VARCHAR(255) vs TEXT, INT vs BIGINT, DECIMAL vs FLOAT. 예: 금액에 FLOAT를 사용하면 부동소수점 오류로 1원 차이가 발생할 수 있다 → DECIMAL 사용 NULL 의미론: NULL은 “알 수 없음/적용 불가”를 의미하지 “0/빈문자열”이 아니다. 3-valued logic(TRUE/FALSE/UNKNOWN)으로 인해 WHERE x != 5가 NULL 행을 제외하는 점을 주의 Enum/Check 제약: 유효한 값 범위를 도메인 수준에서 강제. 예: 상태 코드가 ‘ACTIVE’, ‘INACTIVE’, ‘SUSPENDED’만 허용Keys (PK, FK, Composite, Candidate)erDiagram CUSTOMER ||--o{ ORDER : places ORDER { bigint order_id PK bigint customer_id FK datetime ordered_at } ORDER_ITEM { bigint order_id PK bigint product_id PK int quantity } ORDER ||--|{ ORDER_ITEM : has Candidate Key: 튜플을 유일하게 식별할 수 있는 최소 속성 집합. 하나의 테이블에 여러 후보키가 존재할 수 있다 Primary Key (PK): 후보키 중 선택된 대표 식별자. NOT NULL + UNIQUE Foreign Key (FK): 다른 테이블의 PK를 참조. 참조 무결성(Referential Integrity) 보장 Composite Key: 두 개 이상의 컬럼을 결합한 키. N:M 연결 테이블에서 흔히 사용 Surrogate Key vs Natural Key: Surrogate(대리키): 자동 증가 정수, UUID. 비즈니스 의미 없음. 변경 불요. 조인 성능 좋음 Natural(자연키): 주민번호, 이메일 등 비즈니스 속성. 의미 있지만 변경 가능성이 있고 크기가 클 수 있음 FK 참조 액션: ON DELETE CASCADE: 부모 삭제 시 자식도 삭제. 게시글 → 댓글에 적합 ON DELETE SET NULL: 부모 삭제 시 FK를 NULL로. 담당자 → 작업에 적합 ON DELETE RESTRICT: 참조하는 자식이 있으면 부모 삭제를 거부 (기본값)Auto Increment vs UUID: Auto Increment: 삽입 순서대로 증가. B+ Tree 리프의 끝에만 삽입 → 페이지 분할 최소화. 단, 분산 환경에서 충돌 가능, 갱신량 예측 가능 UUID v4: 128비트 랜덤. 전역 유일하지만 B+ Tree에서 랜덤 삽입 → 페이지 분할 빈번, 캐시 효율 저하, 인덱스 크기 증가 UUID v7 / ULID: 시간 기반 정렬 가능한 UUID. 랜덤 삽입 문제를 완화하면서 전역 유일성 유지Constraintsflowchart TD INS["INSERT/UPDATE"] --> NN{NOT NULL} NN --> UQ{UNIQUE} UQ --> CK{CHECK} CK --> FK{FOREIGN KEY} FK --> OK[커밋 허용] FK --> ERR[Constraint violation]제약 조건은 데이터 무결성의 마지막 방어선이다.주요 제약: NOT NULL: 값이 반드시 존재해야 함 UNIQUE: 중복값 불가. NULL은 복수 허용 (DBMS마다 다름) PRIMARY KEY: NOT NULL + UNIQUE FOREIGN KEY: 참조 무결성. 참조 대상이 존재해야 함 CHECK: 조건식 만족 요구. 예: CHECK (age >= 0 AND age <= 200) DEFAULT: 삽입 시 값이 없으면 기본값 사용 EXCLUSION (PostgreSQL): 범위/공간 겹침 방지. 예: 회의실 예약 시간 겹침 방지제약은 “방어적 프로그래밍”의 데이터 계층 버전이다. 애플리케이션 레이어의 validation은 우회될 수 있지만(직접 SQL 실행, 다른 서비스 경로), DB 제약은 경로에 상관없이 강제된다.4. SQL Deep DiveQuery Execution Orderflowchart LR F[FROM] --> J["JOIN + ON"] J --> W[WHERE] W --> G[GROUP BY] G --> H[HAVING] H --> WIN[WINDOW] WIN --> S[SELECT] S --> D[DISTINCT] D --> U["UNION/INTERSECT/EXCEPT"] U --> O[ORDER BY] O --> L["LIMIT/OFFSET"]논리적 실행 순서:FROM ← 기본 테이블(들) 확정 ↓JOIN + ON ← 조인 결합과 조인 조건 평가 ↓WHERE ← 행 필터링 (집계 전) ↓GROUP BY ← 그룹화 ↓HAVING ← 그룹 필터링 (집계 후) ↓WINDOW ← 윈도우 함수 평가 (OVER) ↓SELECT ← 컬럼 선택, 표현식 계산 ↓DISTINCT ← 중복 제거 ↓UNION/INTERSECT/EXCEPT ← 집합 연산 ↓ORDER BY ← 정렬 (SELECT 별칭 사용 가능) ↓LIMIT/OFFSET ← 결과 행 수 제한이 순서를 아는 것이 중요한 이유: WHERE 절에서 집계 함수를 사용할 수 없다 (GROUP BY 전이므로). 집계 조건은 HAVING에 작성 WHERE에서 SELECT의 별칭을 사용할 수 없다 (SELECT 전이므로). 단, MySQL은 예외적으로 일부 허용 ORDER BY에서는 SELECT의 별칭을 사용할 수 있다 (SELECT 후이므로)실무에서 자주 헷갈리는 포인트: LEFT JOIN + WHERE 필터 함정: RIGHT 테이블 컬럼 조건을 WHERE에 두면 NULL 행이 제거되어 INNER JOIN처럼 동작할 수 있다 WINDOW 함수 위치: 윈도우 함수(ROW_NUMBER, SUM() OVER)는 GROUP BY/HAVING 이후, ORDER BY 이전 단계에서 평가된다 논리 순서 vs 물리 계획: 문법상 순서와 달리 옵티마이저는 조인 순서/접근 방법을 재배치한다. 결과는 같아야 하지만 실행 비용은 크게 달라진다-- 의도: 주문이 없어도 사용자를 유지하고 싶다-- 잘못된 예: WHERE에서 필터하면 LEFT JOIN 의미가 깨질 수 있음SELECT u.id, o.statusFROM users uLEFT JOIN orders o ON o.user_id = u.idWHERE o.status = 'PAID';-- 권장: 조인 조건으로 이동해 OUTER 특성 유지SELECT u.id, o.statusFROM users uLEFT JOIN orders o ON o.user_id = u.id AND o.status = 'PAID';Join 종류 (Nested Loop / Hash Join / Merge Join)flowchart TD Q[join 요청] --> C1{outer가 작고 inner에 index가 있나?} C1 -->|Yes| NL[Nested Loop Join] C1 -->|No| C2{등치 join이고 hash가 메모리에 들어가나?} C2 -->|Yes| HJ[Hash Join] C2 -->|No| MJ["Merge Join<br/>(정렬된 입력)"]조인은 실행 엔진이 데이터 크기/인덱스/정렬 상태를 고려해 전략을 선택한다.Nested Loop Join:for each row r in R (outer): for each row s in S (inner): if r.key == s.key: emit (r, s) 복잡도: $O(\lvert R \rvert \times \lvert S \rvert)$. inner에 인덱스가 있으면 $O(\lvert R \rvert \times \log \lvert S \rvert)$ 적합: outer가 작고, inner에 인덱스가 있을 때. OLTP 소량 조인의 기본 전략 Index Nested Loop: inner의 조인 키에 인덱스를 사용해 탐색. 가장 흔한 OLTP 조인 패턴 Block Nested Loop: outer를 블록 단위로 읽어 inner 스캔 횟수를 줄임Hash Join:Phase 1 (Build): R의 조인 키로 해시 테이블 구성Phase 2 (Probe): S의 각 행에서 해시 테이블을 조회해 매칭 복잡도: $O(\lvert R \rvert + \lvert S \rvert)$. 메모리에 해시 테이블이 들어가야 최적 적합: 대규모 비정렬 조인. 등치 조인(=)만 지원 Grace Hash Join: 메모리 부족 시 양쪽을 파티셔닝한 후 파티션별로 조인Merge Join (Sort-Merge Join):R과 S를 조인 키로 정렬 (이미 정렬되어 있으면 생략)두 포인터를 동시에 이동하며 매칭 복잡도: $O(\lvert R \rvert \log \lvert R \rvert + \lvert S \rvert \log \lvert S \rvert)$. 이미 정렬되어 있으면 $O(\lvert R \rvert + \lvert S \rvert)$ 적합: 양측이 조인 키로 이미 정렬되어 있을 때. 범위 조인에도 사용 가능Subqueryflowchart TD O["outer query(외부 쿼리)"] --> S["Subquery(서브쿼리)"] S --> T{유형} T --> SC[Scalar subquery] T --> EX["EXISTS/IN"] T --> COR[outer row마다 Correlated subquery]서브쿼리는 질의 내부에 포함된 질의다.서브쿼리 유형: 스칼라 서브쿼리: 단일 값 반환. SELECT/WHERE에서 사용. (SELECT MAX(salary) FROM employees) 인라인 뷰: FROM 절의 서브쿼리. 임시 테이블처럼 동작 상관 서브쿼리(Correlated): 외부 쿼리의 각 행에 대해 실행. 성능 이슈의 주요 원인-- 상관 서브쿼리 (외부 행마다 실행될 수 있음)SELECT * FROM employees eWHERE salary > (SELECT AVG(salary) FROM employees WHERE dept_id = e.dept_id);-- 조인으로 재작성 (한 번만 실행)SELECT e.* FROM employees eJOIN (SELECT dept_id, AVG(salary) AS avg_sal FROM employees GROUP BY dept_id) d ON e.dept_id = d.dept_idWHERE e.salary > d.avg_sal;현대 옵티마이저는 상관 서브쿼리를 자동으로 조인으로 변환(decorrelation)하기도 하지만, 모든 경우에 성공하지는 않는다. EXISTS는 IN보다 상관 서브쿼리에서 효율적인 경우가 많다 (첫 번째 매칭에서 멈출 수 있으므로).CTE (Common Table Expression):WITH dept_avg AS ( SELECT dept_id, AVG(salary) AS avg_sal FROM employees GROUP BY dept_id)SELECT e.* FROM employees eJOIN dept_avg d ON e.dept_id = d.dept_idWHERE e.salary > d.avg_sal;가독성을 높이고, 재귀 쿼리(WITH RECURSIVE)로 계층 구조(조직도, 카테고리 트리)를 탐색할 수 있다.Window Functionflowchart LR R[행] --> P[PARTITION BY] P --> O[ORDER BY] O --> F[Window Frame] F --> W["ROW_NUMBER / SUM OVER / LAG"]윈도우 함수는 행을 그룹화하되 결과 행 수를 유지한 채 순위/누적/이전값 비교를 수행한다.구문: function() OVER (PARTITION BY ... ORDER BY ... ROWS/RANGE BETWEEN ... AND ...)주요 함수: 순위: ROW_NUMBER() (고유 번호), RANK() (동률 시 같은 순위, 다음 건너뜀), DENSE_RANK() (동률 시 같은 순위, 다음 연속) 집계: SUM(), AVG(), COUNT(), MIN(), MAX() — OVER 절과 함께 사용하면 누적/이동 집계 탐색: LAG(col, n) (n행 이전 값), LEAD(col, n) (n행 이후 값), FIRST_VALUE(), LAST_VALUE() 분포: NTILE(n) (n등분), PERCENT_RANK(), CUME_DIST()-- 부서별 급여 순위 + 부서 내 급여 비중SELECT emp_id, dept_id, salary, RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS dept_rank, salary::decimal / SUM(salary) OVER (PARTITION BY dept_id) AS salary_ratio, salary - LAG(salary) OVER (PARTITION BY dept_id ORDER BY salary DESC) AS diff_from_prevFROM employees;-- 누적 합계 (running total)SELECT order_date, amount, SUM(amount) OVER (ORDER BY order_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_totalFROM orders;프레임 지정: ROWS BETWEEN 2 PRECEDING AND CURRENT ROW (현재 행 + 이전 2행), RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROW (지난 7일). 이동 평균, 이동 합계에 활용.Index Hintflowchart TD Q[SQL 쿼리] --> OPT[Optimizer 기본 plan] Q --> H[hint 적용 plan 경로] H --> IDX["index 강제 / 선호"] IDX --> EXEC[hint된 access path로 실행] EXEC --> CHECK["EXPLAIN ANALYZE로 검증"] CHECK --> KEEP["유지 + 리뷰 날짜 기록"] CHECK --> DROP["hint 롤백"]인덱스 힌트는 옵티마이저 선택을 보조하거나 강제하는 장치다.-- MySQL: 인덱스 강제/제안/무시SELECT * FROM orders FORCE INDEX (idx_user_date)WHERE user_id = 123 AND order_date > '2024-01-01';-- PostgreSQL: pg_hint_plan 확장/*+ IndexScan(orders idx_user_date) */ SELECT * FROM orders WHERE user_id = 123;힌트가 필요한 상황: 통계가 오래되어 옵티마이저가 잘못된 계획을 선택할 때 (긴급 대응) 특정 인덱스가 더 효율적이라는 것을 알고 있을 때 옵티마이저가 잡지 못하는 데이터 분포 특성이 있을 때장기 해법: ANALYZE 명령으로 통계 갱신, 인덱스 구조 재설계, 쿼리 패턴 변경이 근본적이다. 힌트는 “응급 처치”로만 사용해야 한다.힌트가 오히려 독이 되는 경우: 데이터 분포 변화: 월말/이벤트 트래픽처럼 분포가 바뀌면 예전 힌트가 최악 경로가 될 수 있다 DB 버전 업그레이드: 옵티마이저가 개선되었는데 힌트가 이를 막아 성능이 역행할 수 있다 쿼리 파라미터 편향: 특정 값에서만 빠른 힌트를 전체 요청에 강제하면 평균 지연이 증가한다운영 규칙(권장): 힌트 적용 전/후를 EXPLAIN ANALYZE와 p95/p99 지연으로 비교해 근거를 남긴다 힌트 SQL에 티켓 번호와 적용 이유를 주석으로 기록한다 통계 재수집/인덱스 재설계가 완료되면 힌트를 제거하는 만료 조건을 함께 정의한다5. Storage EnginePage 구조flowchart TD P[DB 페이지] --> HDR[페이지 헤더] P --> SLOTS[slot 디렉터리] P --> FREE[빈 공간] P --> ROWS["Tuple / Record 영역"]스토리지 엔진은 디스크 I/O의 기본 단위로 페이지(page)를 사용한다. InnoDB 기본 16KB, PostgreSQL 8KB.페이지 구조 (일반적):┌─────────────────────────────┐│ Page Header │ ← 페이지 번호, LSN, 체크섬, 여유 공간 포인터├─────────────────────────────┤│ Slot Directory │ ← 각 레코드의 오프셋 포인터 (아래→위 방향)├─────────────────────────────┤│ ││ Free Space ││ │├─────────────────────────────┤│ Record/Tuple Data │ ← 실제 데이터 (위→아래 방향으로 채워짐)├─────────────────────────────┤│ Page Footer │ ← 체크섬 (무결성 확인)└─────────────────────────────┘핵심 개념: 페이지 읽기: 단 1바이트를 읽어도 전체 페이지(8~16KB)를 디스크에서 읽어야 한다. 따라서 “행 수”뿐 아니라 “페이지 수”가 실제 I/O 비용을 결정 버퍼 풀(Buffer Pool): 자주 접근하는 페이지를 메모리에 캐싱. LRU 변형으로 관리. InnoDB는 전체 메모리의 70~80%를 버퍼 풀에 할당하는 것이 일반적 Dirty Page: 메모리에서 수정되었지만 아직 디스크에 기록되지 않은 페이지. 체크포인트 시 디스크에 flushHeap Fileflowchart LR HF[Heap File] --> PG1[Page 1] HF --> PG2[Page 2] HF --> PG3[Page 3] PG1 --> R1[순서 없는 행] PG2 --> R2[unordered rows]Heap file은 레코드를 특정 정렬 없이 저장하는 방식이다. 새 레코드는 여유 공간이 있는 아무 페이지에 삽입된다.장점: 삽입이 빠르다 (정렬 유지 불필요). 순차 스캔(전체 읽기)에 적합단점: 범위 조회/특정 키 탐색은 인덱스 없이는 전체 스캔 필요Free Space Map (FSM): 각 페이지의 여유 공간을 추적하는 메타데이터. 삽입 시 적절한 페이지를 빠르게 찾는 데 사용. PostgreSQL은 FSM을 별도 파일로 관리한다.PostgreSQL은 기본적으로 Heap 구조를 사용하고, 인덱스는 별도 B-Tree로 관리한다(Non-clustered 기본). InnoDB는 PK B+ Tree에 데이터를 직접 저장하는 Clustered 구조가 기본이다.Clustered vs Non-clusteredflowchart LR CI[Clustered Index] --> C1[key 순서로 데이터 행 저장] NCI["Non-clustered Index"] --> N1[index leaf가 row locator를 가리킴] N1 --> N2[추가 lookup이 필요할 수 있음]Clustered Index (클러스터드 인덱스): 테이블 데이터 자체를 인덱스 키 순서로 물리/논리적으로 정렬 저장 테이블당 하나만 존재 (InnoDB에서는 PK가 자동으로 클러스터드 인덱스) 범위 스캔(BETWEEN, ORDER BY)에 매우 효율적: 연속된 페이지를 순차 읽기 랜덤 삽입(UUID PK) 시 페이지 분할이 빈번해 성능 저하Non-clustered Index (비클러스터드 인덱스, Secondary Index): 별도 구조에 (인덱스 키 → 레코드 위치) 매핑을 저장 테이블당 여러 개 생성 가능 Bookmark Lookup: 인덱스에서 위치를 찾은 후, 실제 데이터 페이지를 다시 읽어야 함 (InnoDB에서는 PK로 다시 B+ Tree를 탐색 → “이중 탐색”)InnoDB의 구조:Clustered Index (PK B+ Tree): [PK Key] → [Full Row Data]Secondary Index (B+ Tree): [Secondary Key] → [PK Value] → PK로 Clustered Index를 다시 탐색해 실제 데이터 접근클러스터드 키 선택의 영향: 증가하는 정수 PK: 항상 리프 끝에 삽입. 페이지 분할 최소화. 가장 효율적 UUID v4 PK: 랜덤 삽입. 캐시 미활용, 페이지 분할 빈번. 성능 2~5배 저하 가능 타임스탬프 PK: 시간순 삽입. 증가 정수와 유사한 효과Slotted Pageflowchart TD H[헤더] --> SP[slot pointer 배열] SP --> R1[레코드 A] SP --> R2[레코드 B] SP --> R3[레코드 C] R1 --> MOVE["Records can move, slot id stays stable"]Slotted page는 가변 길이 레코드를 효율적으로 관리하는 페이지 내부 구조다.구조: 페이지 헤더 + 슬롯 디렉터리(고정 크기 배열) + 빈 공간 + 데이터 영역슬롯 디렉터리의 역할: 각 슬롯은 (오프셋, 길이) 쌍으로 레코드 위치를 가리킨다 레코드가 페이지 내에서 이동(compaction)되더라도 슬롯 번호(Record ID의 일부)가 변하지 않으므로, 외부 참조가 안정적으로 유지된다 RID(Record ID) = (Page Number, Slot Number). 인덱스가 저장하는 “포인터”레코드 삭제: 슬롯을 “삭제 표시”로 만들고, 공간은 나중에 재사용. 페이지 내 단편화가 심해지면 compaction(레코드 재배치)을 수행한다.WAL (Write Ahead Logging)sequenceDiagram participant Tx as Transaction participant WAL as WAL File participant DB as Data Page Tx->>WAL: append redo/undo log WAL-->>Tx: fsync complete Tx->>DB: write dirty page (later) Note over WAL,DB: On crash: redo committed, undo incompleteWAL은 데이터 페이지를 디스크에 쓰기 전에 변경 로그를 먼저 기록하는 원칙이다.WAL 규칙: Write-Ahead: 데이터 페이지를 디스크에 flush하기 전에, 해당 변경을 기술하는 로그 레코드가 먼저 디스크에 기록되어야 한다 Force-at-Commit: 트랜잭션 커밋 시 해당 트랜잭션의 모든 로그 레코드가 디스크에 기록되어야 한다 (Durability 보장)WAL이 효율적인 이유: 로그는 순차 쓰기(sequential write)다. 디스크의 순차 쓰기는 랜덤 쓰기보다 수십~수백 배 빠르다 데이터 페이지의 랜덤 쓰기를 지연시키고, 로그의 순차 쓰기로 내구성을 먼저 확보한다 체크포인트(checkpoint)가 주기적으로 dirty 페이지를 디스크에 flush하면, 그 이전 로그는 더 이상 필요 없다ARIES (Algorithm for Recovery and Isolation Exploiting Semantics): 가장 유명한 WAL 기반 복구 알고리즘. 세 단계로 복구: Analysis: 로그를 스캔해 crash 시점의 dirty page 목록과 활성 트랜잭션 파악 Redo: 체크포인트 이후의 모든 로그를 재실행해 crash 직전 상태 복원 (커밋된 것과 안 된 것 모두) Undo: 커밋되지 않은 트랜잭션의 변경을 역순으로 되돌림LSN (Log Sequence Number): 각 로그 레코드의 고유 식별자(단조 증가). 페이지 헤더에 마지막으로 적용된 LSN을 기록해, 복구 시 어디까지 적용했는지 판단한다.6. IndexB-Treegraph TD R(("30 | 60")) --> N1(("10 | 20")) R --> N2(("40 | 50")) R --> N3(("70 | 80")) N1 --> L1["1 · 5 · 9"] N1 --> L2["11 · 15 · 19"] N1 --> L3["21 · 25 · 29"] N2 --> L4["31 · 35 · 39"] N2 --> L5["41 · 45 · 49"] N2 --> L6["51 · 55 · 59"] N3 --> L7["61 · 65 · 69"] N3 --> L8["71 · 75 · 79"] N3 --> L9["81 · 85 · 89"] L1 ~~~ L2 ~~~ L3 ~~~ L4 ~~~ L5 ~~~ L6 ~~~ L7 ~~~ L8 ~~~ L9B-Tree는 균형 다진 트리(balanced multi-way tree) 구조로 탐색/삽입/삭제를 O(log n)에 처리한다.B-Tree 성질 (차수 m): 루트 제외 모든 노드는 최소 ⌈m/2⌉개의 자식을 가진다 모든 리프는 같은 깊이에 존재 (완전 균형) 노드 내 키는 정렬되어 있다왜 디스크에 최적인가: 팬아웃(fan-out)이 크다. 노드 하나에 수백 개의 키를 저장할 수 있어 트리 높이가 매우 낮다 예: 페이지 16KB, 키+포인터 16B → 노드당 ~1000개 자식 → 높이 3에서 10⁹개 키 저장 가능 높이 3 = 루트(캐시됨) + 2번의 디스크 I/O로 수십억 레코드에서 키를 찾을 수 있다삽입/삭제 시 노드 분할(split)/병합(merge)으로 균형을 유지한다. 분할이 루트까지 전파되면 트리 높이가 1 증가한다.B+ Treegraph TD R((Root)) I1((Internal)) I2((Internal)) L1[Leaf 1] L2[Leaf 2] L3[Leaf 3] L4[Leaf 4] R --> I1 R --> I2 I1 --> L1 I1 --> L2 I2 --> L3 I2 --> L4 L1 --- L2 L2 --- L3 L3 --- L4B+ Tree는 B-Tree의 변형으로, 실제 DBMS 인덱스의 사실상 표준이다.B-Tree와의 차이: 내부 노드: 키만 저장 (데이터/포인터 없음) → 노드당 더 많은 키 → 팬아웃 더 큼 → 트리 더 낮음 리프 노드: 실제 데이터(또는 레코드 포인터)를 저장. 리프를 링크드 리스트로 연결 → 범위 스캔이 리프 수준에서 순차적으로 진행Internal: [10 | 20 | 30] / | | \Leaf: [1,5,7]->[10,12,15]->[20,22,25]->[30,35,40] ←────── 양방향 연결 리스트 ──────→범위 스캔 성능: WHERE price BETWEEN 20 AND 35 쿼리 시, B+ Tree는 루트에서 key=20을 찾은 후 리프 링크를 따라 key≤35까지 순차 스캔한다. B-Tree에서는 이 연결이 없어 매번 루트에서 다시 탐색해야 할 수 있다.Buffer Tree: 배치 삽입 최적화. 내부 노드에 버퍼를 두어 삽입을 모았다가 한 번에 하위로 전파. LSM 트리의 아이디어와 유사.Hash Indexflowchart LR K[검색 key] --> H[Hash 함수] H --> BKT[버킷] BKT --> E1[엔트리] BKT --> E2[overflow chain]해시 인덱스는 동등 비교(=)에 매우 빠르지만 범위 질의(<, >, BETWEEN, ORDER BY)에는 부적합하다.구조: 해시 함수로 키를 버킷 번호에 매핑. 버킷에 레코드 포인터를 저장.사용 예: MySQL Memory 엔진의 기본 인덱스 PostgreSQL의 Hash Index (CREATE INDEX … USING HASH) InnoDB Adaptive Hash Index: 자주 접근하는 B+ Tree 리프 페이지에 대해 자동으로 해시 인덱스를 메모리에 구성확장 가능 해싱(Extensible Hashing): 데이터 증가 시 전체 리빌드 없이 점진적으로 버킷을 분할하는 기법. 디렉터리(해시 비트 수)를 확장하며, 분할이 필요한 버킷만 분할한다.Linear Hashing: 라운드 로빈 방식으로 순서대로 버킷을 분할. 디렉터리 불필요.Covering Indexflowchart TD Q["SELECT name FROM users WHERE email = ?"] --> I["(email, name) index"] I --> C{필요한 컬럼이 index에 모두 있나?} C -->|Yes| O["Index-only scan"] C -->|No| T["table / heap row lookup"]커버링 인덱스는 쿼리에 필요한 모든 컬럼을 인덱스만으로 제공해 테이블 본문(heap/clustered index) 접근을 없애는 전략이다.-- 비커버링: 인덱스에서 PK를 찾고, 다시 테이블에서 name을 읽어야 함CREATE INDEX idx_user_age ON users(age);SELECT name, age FROM users WHERE age > 25;-- 커버링: 인덱스만으로 name, age 모두 제공 (Index Only Scan)CREATE INDEX idx_user_age_name ON users(age, name);SELECT name, age FROM users WHERE age > 25;EXPLAIN에서 “Using index” (MySQL) 또는 “Index Only Scan” (PostgreSQL)이 표시되면 커버링 인덱스가 적용된 것이다.INCLUDE 절 (PostgreSQL 11+, SQL Server): 인덱스 키에 포함하지 않지만 리프에 같이 저장할 컬럼을 지정. 정렬/중복 판단에는 사용하지 않으면서 커버링 효과를 얻는다.CREATE INDEX idx_user_age ON users(age) INCLUDE (name, email);PostgreSQL 주의점: Index Only Scan이 보이더라도 모든 경우에 heap 접근이 0은 아니다. 가시성(visibility) 확인을 위해 Visibility Map의 all-visible 비트가 꺼진 페이지는 heap 확인이 필요하다. 즉, 갱신이 잦은 테이블에서는 index-only 이점이 줄어들 수 있다.설계 체크리스트: WHERE/JOIN/ORDER BY에 쓰는 컬럼을 먼저 키 컬럼으로 배치한다 SELECT에만 필요한 컬럼은 가능하면 INCLUDE로 넣어 키 팽창을 줄인다 커버링 인덱스 추가 전, 쓰기 비용(INSERT/UPDATE/DELETE) 증가와 인덱스 크기 증가를 반드시 같이 평가한다실무 트레이드오프: 장점: 랜덤 I/O 감소, 지연 단축, CPU 캐시 효율 개선 단점: 인덱스 저장공간 증가, DML write amplification, 버퍼 캐시 압박Composite Indexflowchart LR IDX["Index (A,B,C)"] --> P1[A 지원] IDX --> P2["A,B 지원"] IDX --> P3["A,B,C 지원"] IDX --> X["B,C만으로는 직접 지원 안 함"]복합 인덱스는 여러 컬럼을 순서대로 결합한다. 순서가 성능을 결정한다.Leftmost Prefix Rule (선두 컬럼 규칙): 복합 인덱스 (A, B, C)에서: A 조건 → 인덱스 사용 ✅ A, B 조건 → 인덱스 사용 ✅ A, B, C 조건 → 인덱스 완전 사용 ✅ B, C 조건 (A 없음) → 인덱스 비사용 ❌ (Index Skip Scan 예외 가능) A, C 조건 (B 없음) → A까지만 인덱스 사용, C는 필터링컬럼 순서 설계 원칙: 동등 조건(=) 컬럼을 앞에, 범위 조건(<, >, BETWEEN) 컬럼을 뒤에: 범위 조건 이후의 컬럼은 인덱스 탐색에 활용되지 않음 카디널리티가 높은 컬럼을 선행: 선택도가 높아야 탐색 범위가 좁아짐 (항상 그런 것은 아님, 쿼리 패턴 의존) 정렬(ORDER BY) 컬럼 포함: 인덱스 순서와 정렬 순서가 일치하면 추가 정렬 불필요 (filesort 회피)Index Scan vs Full Scanflowchart TD Q[쿼리 predicate] --> SEL{선택도가 충분한가?} SEL -->|Yes| IS[Index Scan] SEL -->|No| FS[Full Table Scan] IS --> COST["선택도가 높으면 random I/O 감소"] FS --> COST인덱스 스캔이 항상 빠른 것은 아니다.인덱스 스캔이 유리한 경우: 높은 선택도: 전체 행 중 소수만 조건에 매칭 (보통 5~15% 이하) 정렬된 결과가 필요할 때 (인덱스 정렬 순서 활용) 커버링 인덱스가 존재할 때풀 스캔이 유리한 경우: 낮은 선택도: 전체의 20~30% 이상을 읽어야 할 때. 순차 I/O(전체 페이지 읽기)가 랜덤 I/O(인덱스 + 테이블 접근)보다 빠를 수 있다 작은 테이블: 전체가 버퍼 풀에 있을 때 인덱스가 없을 때 (당연)옵티마이저의 판단: 통계(히스토그램, distinct 값 수, null 비율)를 기반으로 인덱스 스캔의 I/O 비용 vs 풀 스캔의 I/O 비용을 비교해 결정한다. 통계가 부정확하면 잘못된 결정이 내려질 수 있다 → ANALYZE 명령으로 갱신.Bitmap Index Scan (PostgreSQL): 여러 인덱스의 결과를 비트맵으로 결합한 후 테이블을 스캔하는 전략. 선택도가 중간(5~20%)일 때 인덱스 스캔과 시퀀셜 스캔의 중간 효율을 제공한다.7. TransactionACIDflowchart TD A["ACID"] --> B["원자성 (Atomicity)"] A --> C["일관성 (Consistency)"] A --> D["격리성 (Isolation)"] A --> E["지속성 (Durability)"] Atomicity (원자성): 트랜잭션의 모든 연산이 성공하거나, 모두 실패한 것처럼 되돌아간다. 구현 수단은 DBMS마다 다르며 undo logging, shadow paging, compensation 등의 방식이 쓰인다 Consistency (일관성): 트랜잭션 전후 모든 제약조건(PK, FK, CHECK 등)이 만족된다. DB 제약과 애플리케이션 로직이 함께 보장 Isolation (격리성): 동시 실행되는 트랜잭션이 서로 간섭하지 않는 것처럼 보인다. 격리 수준에 따라 정도가 다름. 구현: 락, MVCC Durability (지속성): 커밋된 트랜잭션의 결과는 시스템 장애 후에도 보존된다. 보통 WAL/redo logging, 체크포인트, 복제와 fsync 계열 동기화가 함께 사용된다ACID에서 가장 미묘한 것은 Isolation이다. 완전한 격리(Serializable)는 성능 비용이 크므로, 대부분의 시스템은 더 약한 격리 수준을 기본으로 사용한다.Isolation Levelflowchart LR R0[Read Uncommitted] --> R1[Read Committed] R1 --> R2[Repeatable Read] R2 --> R3[Serializable] R0 -. dirty read 가능 .-> X1[이상 현상] R1 -. non-repeatable read possible .-> X1 R2 -. write skew/phantom depending engine .-> X1 격리 수준 Dirty Read Non-repeatable Read Phantom Read 성능 Read Uncommitted 가능 가능 가능 최고 Read Committed 차단 가능 가능 높음 Repeatable Read 차단 차단 가능(구현 의존)* 중간 Serializable 차단 차단 차단 낮음 이상 현상(Anomaly): Dirty Read: 다른 트랜잭션이 아직 커밋하지 않은 데이터를 읽음 → 롤백되면 읽은 데이터가 무효 Non-repeatable Read: 같은 트랜잭션 내에서 같은 행을 두 번 읽었는데 결과가 다름 (다른 트랜잭션이 수정/커밋) Phantom Read: 같은 조건으로 두 번 쿼리했는데 행의 수가 달라짐 (다른 트랜잭션이 INSERT/DELETE)*Repeatable Read에서 phantom 처리 방식은 DBMS 구현에 따라 다르다. InnoDB는 일관 읽기(MVCC)와 잠금 읽기(Next-Key/Gap Lock)의 조합으로 많은 케이스를 방지하지만, SERIALIZABLE과 동일 의미는 아니다.기본 격리 수준: PostgreSQL: Read Committed MySQL InnoDB: Repeatable Read Oracle: Read Committed SQL Server: Read CommittedWrite Skew: Repeatable Read에서도 발생 가능한 이상 현상. 두 트랜잭션이 각각 다른 행을 수정하지만, 논리적 제약(예: “최소 1명은 당직”)이 위반되는 경우. Serializable 격리 수준이나 명시적 잠금(SELECT FOR UPDATE)으로만 방지 가능.MVCCsequenceDiagram participant T1 as Tx1 participant T2 as Tx2 participant DB as DB (MVCC) T1->>DB: BEGIN (snapshot S1) T2->>DB: BEGIN (snapshot S2) T1->>DB: UPDATE row -> new version v2 T2->>DB: SELECT row DB-->>T2: returns version visible in S2 T1->>DB: COMMIT T2->>DB: COMMIT or retry on conflictMVCC(Multi-Version Concurrency Control)는 다중 버전 데이터를 통해 읽기와 쓰기 충돌을 줄이는 방식이다. 읽기가 쓰기를 블로킹하지 않고, 쓰기가 읽기를 블로킹하지 않는다.PostgreSQL MVCC 구현: 각 행에 xmin(생성 트랜잭션 ID), xmax(삭제 트랜잭션 ID)를 저장 UPDATE = 기존 행의 xmax 설정 + 새 버전 INSERT. DELETE = xmax만 설정 트랜잭션은 자신의 스냅샷(active 트랜잭션 목록) 기준으로 보이는 버전만 읽음 VACUUM: 더 이상 어떤 트랜잭션도 참조하지 않는 오래된 버전(dead tuples)을 정리. VACUUM을 안 하면 테이블이 계속 커지고(table bloat) 성능 저하InnoDB MVCC 구현: Undo Log에 이전 버전을 저장. 행에서 Undo 포인터로 이전 버전을 찾아감 (버전 체인) Read View: 트랜잭션 시작 시점의 활성 트랜잭션 목록을 기준으로 가시성 판단 Purge Thread: 더 이상 필요 없는 Undo 레코드를 정리MVCC의 장점: 읽기-쓰기 간 블로킹 없음 → OLTP 환경에서 매우 높은 동시성MVCC의 비용: 오래된 버전 관리(VACUUM/Purge), 스냅샷 관리 메모리, 긴 트랜잭션이 있으면 오래된 버전을 정리하지 못해 bloat 발생Lock (Shared / Exclusive)graph TD S1["Tx A: S lock"] --> R[Row X] S2["Tx B: S lock"] --> R X1["Tx C: X lock 요청"] -. S lock 해제까지 대기 .-> R Shared Lock (S-lock, 공유 잠금): 읽기 목적. 여러 트랜잭션이 동시에 S-lock을 획득 가능. S-lock이 걸린 자원에 X-lock은 불가 Exclusive Lock (X-lock, 배타 잠금): 쓰기 목적. X-lock이 걸린 자원에 다른 어떤 잠금도 불가 S-lock 보유 X-lock 보유 S-lock 요청 허용 ✅ 대기 ❌ X-lock 요청 대기 ❌ 대기 ❌ 잠금 단위(Lock Granularity): 행(Row) 잠금: 동시성 최고. 잠금 관리 오버헤드 큼. InnoDB 기본 페이지(Page) 잠금: 중간 테이블(Table) 잠금: 동시성 최저. 오버헤드 최소. MyISAM, DDL 연산Intention Lock (의향 잠금): 계층적 잠금에서 상위 노드에 “하위 어딘가에 S/X 잠금이 있음”을 표시. IS(Intention Shared), IX(Intention Exclusive). 테이블 전체 잠금 시 모든 행을 확인하지 않고 의향 잠금만 확인하면 된다.Gap Lock / Next-Key Lock (InnoDB): 인덱스 레코드 사이의 “간격”을 잠그는 것. Phantom Read 방지 목적. 예: 인덱스에 10, 20이 있을 때 “10-20 사이 간격”을 잠가 다른 트랜잭션의 INSERT를 차단한다.Deadlockgraph LR T1((Tx1)) -->|대기| L2["Lock B"] L2 -->|보유 중| T2((Tx2)) T2 -->|대기| L1["Lock A"] L1 -->|보유 중| T1데드락은 서로가 상대 락 해제를 기다리며 진행이 멈춘 상태다.TX1: Lock(A) → Lock(B) 대기TX2: Lock(B) → Lock(A) 대기→ 서로 상대방의 락을 기다리며 무한 대기데드락 대응 전략: 데드락 탐지 (Detection): Wait-For 그래프에서 사이클을 감지하고, 사이클의 한 트랜잭션을 롤백(victim 선택). InnoDB는 주기적으로 탐지하며, 비용이 낮은 트랜잭션을 victim으로 선택 데드락 예방 (Prevention): 모든 트랜잭션이 동일한 순서로 자원을 잠그도록 강제. Wait-Die/Wound-Wait 프로토콜 Wait-Die: 오래된 TX가 기다리고, 새 TX가 기다려야 하면 롤백(die) Wound-Wait: 오래된 TX가 새 TX를 롤백시키고(wound) 진행 데드락 회피 (Avoidance): 타임아웃 기반. 일정 시간 대기 후 자동 롤백. 단순하지만 정확하지 않음애플리케이션 수준 대응: 트랜잭션 범위(시간)를 최소화 자원 접근 순서를 일관되게 유지 (예: PK 오름차순으로 UPDATE) 재시도 로직 필수 (롤백된 트랜잭션은 재실행 가능) NOWAIT / SKIP LOCKED: 잠금 대기 없이 즉시 실패하거나, 잠긴 행을 건너뜀8. Query OptimizationCost Based Optimizerflowchart TD SQL[SQL 쿼리] --> R1[rewrite 규칙] R1 --> P1[후보 plan 생성] P1 --> C1["cardinality + I/O/CPU cost 추정"] C1 --> P2[최저 cost plan 선택] P2 --> EX["Execute(실행)"]CBO(Cost-Based Optimizer)는 후보 실행 계획들의 비용을 추정해 최적 계획을 선택한다.옵티마이저 동작 과정: 파싱(Parsing): SQL → AST(Abstract Syntax Tree) 의미 분석: 테이블/컬럼 존재 확인, 타입 호환성 체크 논리 최적화 (Rule-based transformations): Selection Pushdown: WHERE 조건을 조인 전으로 이동 Projection Pushdown: 필요한 컬럼만 일찍 선택 Predicate Simplification: 항상 참인 조건 제거 View Merging: 뷰/서브쿼리를 풀어 최적화 기회 확대 물리 최적화 (Cost-based plan selection): 동일 결과를 생성하는 여러 물리 계획(조인 순서, 조인 방식, 인덱스 선택, 정렬 방식) 열거 각 계획의 비용(CPU 연산 수, 디스크 I/O 페이지 수, 메모리 사용량) 추정 최저 비용 계획 선택 조인 순서 탐색: N개 테이블의 가능한 조인 순서는 N!개. N이 클 때는 동적 프로그래밍(최적) 또는 유전 알고리즘/탐욕법(근사)으로 탐색 공간을 줄인다. PostgreSQL은 12개 이상 테이블 조인 시 유전 알고리즘(GEQO)을 사용한다.Execution Plan 분석flowchart TD EX["EXPLAIN/ANALYZE"] --> N1["access method (scan 유형)"] EX --> N2[join 순서 / join 유형] EX --> N3[추정 행 수 vs 실제 행 수] N3 --> ACT["index / statistics / query 튜닝"]실행 계획(EXPLAIN/EXPLAIN ANALYZE)은 쿼리 성능 문제 진단의 출발점이다.PostgreSQL EXPLAIN ANALYZE 읽기:EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)SELECT * FROM orders o JOIN users u ON o.user_id = u.idWHERE o.status = 'pending';Hash Join (cost=10.50..100.00 rows=50 width=120) (actual time=0.5..2.0 rows=42 loops=1) Hash Cond: (o.user_id = u.id) -> Seq Scan on orders o (cost=0.00..80.00 rows=50 width=80) (actual time=0.01..1.0 rows=42 loops=1) Filter: (status = 'pending') Rows Removed by Filter: 958 Buffers: shared hit=50 -> Hash (cost=8.00..8.00 rows=200 width=40) (actual time=0.4..0.4 rows=200 loops=1) -> Seq Scan on users u핵심 지표: estimated vs actual rows: 차이가 크면 통계 부정확 → ANALYZE 실행 Buffers: shared hit vs read: read가 많으면 버퍼 풀 부족 또는 워킹셋 초과 Seq Scan + Filter + Rows Removed: 많은 행을 스캔 후 버리는 것 → 인덱스 추가 검토 Sort Method: external merge: 메모리 부족으로 디스크 정렬 → work_mem 증가 검토Cardinality Estimationflowchart TD PRED[Predicate] --> STAT["histogram / NDV / correlation 통계"] STAT --> EST[추정 행 수] EST --> PLAN[join 순서와 operator 선택]카디널리티 추정은 각 연산 단계에서 출력되는 행 수를 예측하는 과정이다.추정 기법: 균일 분포 가정: WHERE age = 25 → estimated rows = total_rows / NDV(age). 실제 데이터가 편향되어 있으면 크게 틀림 히스토그램: 데이터 분포를 버킷으로 나누어, 각 범위의 빈도를 근사. 편향된 데이터에서 정확도를 크게 높임 MCV (Most Common Values): 가장 빈번한 값들의 빈도를 별도로 기록. WHERE status = 'active'에서 95%가 active이면, 균일 분포 가정은 크게 틀리지만 MCV가 정확한 추정을 제공 독립성 가정: 다중 조건 WHERE A=1 AND B=2 → $P(A=1) \times P(B=2) \times total$. 실제 A와 B가 상관되어 있으면 오추정. PostgreSQL의 create_statistics로 다중 컬럼 통계를 수집할 수 있다카디널리티 오추정이 미치는 영향: 예상 10행 → 실제 100만 행: Nested Loop Join(10행에 최적)이 선택되었지만, 실제로는 Hash Join이 필요 잘못된 조인 순서: 큰 결과가 먼저 나오면 메모리/디스크 비용 폭발 잘못된 메모리 할당: Sort/Hash에 할당량이 부족해 디스크 스필(spill) 발생Statisticsflowchart LR DATA[테이블 데이터 분포] --> ANALYZE[통계 수집] ANALYZE --> CATALOG[system catalog 통계] CATALOG --> OPT[Optimizer cost model]통계 정보는 옵티마이저의 핵심 입력이다.수집되는 통계 (PostgreSQL pg_stats): n_distinct: 고유값 수 (음수면 행 수 대비 비율) most_common_vals / most_common_freqs: MCV와 그 빈도 histogram_bounds: 균등 깊이 히스토그램의 경계값 null_frac: NULL 비율 avg_width: 평균 행 너비 (바이트) correlation: 물리적 순서와 논리적 순서의 상관관계 (-1~1). 높으면 인덱스 스캔 시 순차 I/O에 가까워 효율적통계 갱신:ANALYZE table_name; -- 특정 테이블 통계 수집ANALYZE table_name(column); -- 특정 컬럼만ALTER TABLE t ALTER COLUMN c SET STATISTICS 1000; -- 히스토그램 버킷 수 증가 (기본 100)자동 분석(autovacuum/auto-analyze)이 있지만, 대량 데이터 적재 후에는 수동 ANALYZE가 필요할 수 있다. 통계가 오래되면 옵티마이저가 잘못된 계획을 선택하는 주요 원인이 된다.9. Concurrency Control2PL (Two Phase Locking)flowchart LR G[Growing 단계<br/>lock 획득] --> P[lock point] P --> S[Shrinking 단계<br/>lock 해제] S --> E[새 lock 획득 불가]2PL은 직렬화 가능성(Serializability)을 보장하는 고전적 동시성 제어 기법이다.두 단계: Growing Phase (확장 단계): 잠금을 획득만 할 수 있고, 해제는 불가 Shrinking Phase (축소 단계): 잠금을 해제만 할 수 있고, 새 획득은 불가2PL 변형: Basic 2PL: 위 규칙 그대로. cascading rollback(연쇄 롤백) 가능 Strict 2PL (S2PL): 쓰기 잠금(X-lock)을 커밋/롤백까지 유지. 연쇄 롤백 방지 Strong Strict 2PL (SS2PL): 모든 잠금을 커밋/롤백까지 유지. 가장 단순하고 안전하지만 동시성이 가장 낮다. 대부분의 DBMS가 SS2PL에 가까운 방식을 사용2PL과 데드락: 2PL은 교착 상태를 방지하지 않는다. 잠금 그래프에서 사이클이 형성될 수 있다. 별도의 데드락 탐지/예방이 필요하다.Optimistic LocksequenceDiagram participant T1 as Tx1 participant T2 as Tx2 participant DB T1->>DB: read row (version=5) T2->>DB: read row (version=5) T1->>DB: update ... where version=5 DB-->>T1: success (version=6) T2->>DB: update ... where version=5 DB-->>T2: 0 rows -> 충돌 / 재시도낙관적 잠금(OCC, Optimistic Concurrency Control)은 충돌이 드물다는 가정 하에, 읽기 시 잠금 없이 진행하고 커밋 시 충돌을 검증한다.3단계: Read Phase: 트랜잭션이 데이터를 읽고 로컬에서 작업. 잠금 없이 진행 Validation Phase: 커밋 전에 다른 트랜잭션과 충돌이 없었는지 검증 Write Phase: 검증 통과 시 변경 사항을 DB에 적용애플리케이션 수준 낙관적 잠금 (가장 흔한 형태):-- 1. 버전과 함께 읽기SELECT id, name, version FROM users WHERE id = 1;-- 결과: id=1, name='Alice', version=5-- 2. 수정 시 버전 조건 포함UPDATE users SET name = 'Bob', version = 6WHERE id = 1 AND version = 5;-- rows_affected = 0 이면 → 다른 트랜잭션이 이미 수정 → 재시도적합한 상황: 읽기 빈도 » 쓰기 빈도, 충돌이 드문 환경. 부적합: 경합이 심한 환경 (재시도가 빈번해 오히려 성능 저하)Timestamp Orderingflowchart TD OP["Read/Write 연산"] --> TS["readTS / writeTS와 비교"] TS --> OK{순서가 유효한가?} OK -->|Yes| APPLY[연산 적용] OK -->|No| ABORT["transaction abort / restart"]타임스탬프 기반 순서 제어(T/O)는 각 트랜잭션에 시작 시 타임스탬프를 부여하고, 타임스탬프 순서대로 실행된 것과 동일한 결과를 보장한다.규칙: 데이터 항목 X에 대해 마지막 읽기 타임스탬프 R-TS(X)와 쓰기 타임스탬프 W-TS(X)를 유지 트랜잭션 Ti (타임스탬프 = TS(Ti))가 X를 읽을 때: TS(Ti) < W-TS(X)이면 거부 (자기보다 나중 트랜잭션이 이미 쓴 값 → 읽으면 비일관) 트랜잭션 Ti가 X를 쓸 때: TS(Ti) < R-TS(X) 또는 TS(Ti) < W-TS(X)이면 거부Thomas Write Rule: TS(Ti) < W-TS(X)인 쓰기를 거부하는 대신 무시(skip)하는 최적화. 어차피 나중 트랜잭션의 쓰기가 덮어쓸 것이므로.실전 시스템에서 순수 T/O는 드물고, MVCC와 결합된 형태(Snapshot Isolation, Serializable Snapshot Isolation)가 일반적이다.SSI (Serializable Snapshot Isolation)graph LR T1((Tx1)) -->|rw-antidependency| T2((Tx2)) T2 -->|rw-antidependency| T3((Tx3)) T3 -->|잠재적 dangerous structure| T1SSI는 Snapshot Isolation의 성능을 유지하면서 직렬화 가능성을 보장하는 현대적 동시성 제어 기법이다. SI의 약점인 Write Skew 이상 현상을 추가 검증으로 방지한다.SI(Snapshot Isolation)의 한계: Write SkewSI는 각 트랜잭션이 일관된 스냅샷을 읽고 쓰기 충돌(같은 행 동시 수정)만 막지만, 서로 다른 행을 읽고 조건 기반으로 쓸 때 발생하는 Write Skew를 허용한다:의사 당직 예시 (최소 1명 항상 유지):T1: SELECT count(*) FROM on_call → 2명 (자기 포함)T2: SELECT count(*) FROM on_call → 2명 (자기 포함)T1: UPDATE SET on_call=false WHERE id=1 -- 2명이니 빠져도 됨T2: UPDATE SET on_call=false WHERE id=2 -- 2명이니 빠져도 됨결과: 둘 다 빠짐 → 0명 → 제약조건 위반SSI의 동작 원리SSI는 MVCC 기반 SI에 직렬화 가능성 검증 레이어를 추가한다: Read Tracking: 각 트랜잭션이 읽은 데이터(predicate reads 포함)를 추적 Conflict Detection: 커밋 시 “rw-antidependency” 사이클을 검사. T1이 읽은 데이터를 T2가 수정했고, T2가 읽은 데이터를 T1이 수정했다면 위험한 구조 Abort: 사이클이 탐지되면 트랜잭션 중 하나를 abort. 나머지는 진행SSI는 낙관적(optimistic) 기법이다: 잠금 없이 진행하고, 커밋 시점에만 검증 충돌이 적은 워크로드에서 2PL보다 훨씬 높은 처리량 False positive abort가 발생할 수 있지만(실제로는 안전한 트랜잭션도 보수적으로 abort), 안전성은 항상 보장PostgreSQL의 SSI 구현 (9.1+)PostgreSQL은 SERIALIZABLE 격리 수준에서 SSI를 사용한다:-- PostgreSQL에서 SSI 활성화SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;BEGIN;SELECT * FROM accounts WHERE balance > 100; -- predicate read 추적UPDATE accounts SET balance = balance - 50 WHERE id = 1;COMMIT; -- 커밋 시 rw-conflict 검사, 위반 시 serialization failure내부 구현: SIREAD Lock: 읽기에 대한 “잠금”이지만 블로킹하지 않음. 어떤 데이터가 읽혔는지를 기록만 함 rw-conflict 그래프: T1→T2 (T1이 읽은 데이터를 T2가 수정)와 T2→T1을 동시에 감지 두 개의 연속된 rw-conflict 간선(dangerous structure) 이 감지되면 트랜잭션 abort 메모리 관리: SIREAD Lock은 Summary 구조로 압축 (페이지 → 릴레이션 수준으로 escalation)에러 처리: ERROR: could not serialize access due to read/write dependencies among transactions → 재시도 루프 필수DB별 동시성 제어 비교flowchart LR DB[DB 엔진] --> PG["PostgreSQL: MVCC + SSI"] DB --> MY["InnoDB: MVCC + next-key lock"] PG --> P1["Snapshot tuples + VACUUM"] MY --> M1["Undo log + purge"]PostgreSQL 동시성 모델 격리 수준 내부 구현 특징 READ COMMITTED 문장별 새 스냅샷 기본값, Non-repeatable Read 허용 REPEATABLE READ 트랜잭션 시작 시 스냅샷 고정 Phantom 방지, Write Skew 허용 SERIALIZABLE SSI (SIREAD Lock + conflict detection) 완전한 직렬화, abort 재시도 필요 PostgreSQL MVCC 특이점: xmin/xmax 방식: 튜플 헤더에 생성/삭제 트랜잭션 ID를 직접 기록. 인덱스 업데이트도 필요 (HOT 최적화 제외) VACUUM 필수: 죽은 튜플(dead tuple)을 물리적으로 회수. VACUUM 지연 시 테이블 팽창(bloat) Predicate Lock 아님: SSI에서 인덱스 범위 잠금이 아닌 SIREAD Lock을 사용해 블로킹 없이 추적InnoDB (MySQL) 동시성 모델 격리 수준 내부 구현 특징 READ COMMITTED 문장별 ReadView 생성 Oracle 기본과 유사 REPEATABLE READ 트랜잭션 시작 시 ReadView 고정 + Gap Lock 기본값, Phantom을 Gap Lock으로 방지 SERIALIZABLE REPEATABLE READ보다 더 강한 잠금/검증 사용 읽기 동시성이 줄지만 직렬화 가능성 제공 InnoDB MVCC 특이점: Undo Log 기반: 수정 전 버전을 Undo Log에 보관. 원본 페이지는 항상 최신 버전 ReadView: 활성 트랜잭션 ID 목록의 스냅샷. 각 버전의 가시성을 trx_id 비교로 판단 Clustered Index 직접 수정: 기본 키 인덱스의 리프에 데이터를 직접 수정하고, 이전 버전은 Undo Log 체인으로 연결 Purge Thread: 더 이상 참조되지 않는 Undo Log를 비동기로 정리핵심 차이 요약 관점 PostgreSQL InnoDB 이전 버전 저장 힙에 새 튜플 생성 (xmin/xmax) Undo Log에 이전 버전 보관 공간 회수 VACUUM (명시적) Purge Thread (자동) SERIALIZABLE SSI (낙관적, 비차단) S2PL (비관적, 차단) Gap 방지 SSI가 predicate read 추적 Gap Lock / Next-Key Lock 인덱스 갱신 새 버전마다 인덱스 갱신 (HOT 제외) 클러스터드 인덱스 직접 수정 장기 트랜잭션 영향 테이블 bloat (VACUUM 불가) Undo Log 누적, 읽기 성능 저하 ORM 수준 낙관적 잠금 패턴sequenceDiagram participant App participant ORM participant DB App->>ORM: load entity(version=7) App->>ORM: modify fields ORM->>DB: UPDATE ... WHERE id=? AND version=7 DB-->>ORM: affected rows 1 or 0 ORM-->>App: success or OptimisticLockException애플리케이션/ORM 레벨에서 구현하는 낙관적 잠금은 DB의 격리 수준과 독립적으로 동작하며, 비즈니스 로직의 동시성 충돌을 방어한다.@Version 어노테이션 (JPA/Hibernate)@Entitypublic class Product { @Id private Long id; private String name; private int price; @Version private int version; // Hibernate가 자동 관리}// 동작 흐름:// 1. SELECT: Product {id=1, name="Widget", price=100, version=3}// 2. 비즈니스 로직에서 price = 120으로 수정// 3. Hibernate가 발행하는 UPDATE:// UPDATE product SET name='Widget', price=120, version=4// WHERE id=1 AND version=3// 4. affected rows = 0이면 → OptimisticLockException 발생@Version 지원 타입: int/Integer, long/Long, short/Short, java.sql.Timestamp재시도 패턴@Retryable(value = OptimisticLockException.class, maxAttempts = 3, backoff = @Backoff(delay = 100))@Transactionalpublic void updateProductPrice(Long id, int newPrice) { Product product = productRepository.findById(id).orElseThrow(); product.setPrice(newPrice); productRepository.save(product); // flush 시 version 검증}// 수동 재시도 (Spring Retry 없이)public void updateWithRetry(Long id, int newPrice) { for (int attempt = 0; attempt < 3; attempt++) { try { updateProductPrice(id, newPrice); return; } catch (OptimisticLockException e) { if (attempt == 2) throw e; // 다음 시도는 최신 version을 다시 읽어옴 } }}비관적 잠금과의 비교 (JPA)// 비관적 잠금: SELECT ... FOR UPDATE 발행@Lock(LockModeType.PESSIMISTIC_WRITE)@Query("SELECT p FROM Product p WHERE p.id = :id")Product findByIdWithLock(@Param("id") Long id);// → 다른 트랜잭션이 같은 행을 잠그려 하면 대기(wait) 또는 timeout// 낙관적 잠금: 잠금 없이 진행, 커밋 시 version 검증@Lock(LockModeType.OPTIMISTIC)@Query("SELECT p FROM Product p WHERE p.id = :id")Product findByIdOptimistic(@Param("id") Long id); 특성 낙관적 잠금 (@Version) 비관적 잠금 (FOR UPDATE) 잠금 시점 커밋 시 (사후 검증) 조회 시 (사전 차단) 충돌 비용 재시도 (rollback + retry) 대기 (blocking) 적합 환경 읽기 많고 경합 적은 OLTP 경합 심한 재고/결제 데드락 위험 없음 있음 (lock ordering 필요) 확장성 높음 (잠금 없는 동시 진행) 낮음 (잠금 경합 병목) Shardingflowchart TD App[애플리케이션] --> Router[Shard Router] Router --> S1["Shard A<br/>user_id 0-999k"] Router --> S2["Shard B<br/>user_id 1M-1.99M"] Router --> S3["Shard C<br/>user_id 2M+"]샤딩은 데이터를 여러 노드로 수평 분할해 저장하는 방식이다.샤딩 전략: Range Sharding: 키의 범위로 분할. 예: user_id 1~100만 → 샤드1, 100만~200만 → 샤드2. 범위 쿼리에 유리하지만 핫스팟 위험 (최근 가입 사용자가 한 샤드에 집중) Hash Sharding: 키의 해시값으로 분할. 균등 분배. 범위 쿼리 비효율. 핫스팟 위험 낮음 Consistent Hashing: 해시 링에 노드를 배치. 노드 추가/제거 시 최소 데이터 이동. DynamoDB, Cassandra가 채택 Directory-Based Sharding: 별도 매핑 테이블로 키 → 샤드 관리. 유연하지만 디렉터리가 SPOF(Single Point of Failure)샤딩의 도전과제: 크로스샤드 조인: 여러 샤드에 걸친 데이터 조인은 네트워크 비용 + 조율 비용이 크다. 가능하면 같이 조회하는 데이터를 같은 샤드에 배치(co-location) 크로스샤드 트랜잭션: 분산 트랜잭션(2PC) 필요. 성능과 가용성 비용이 큼 리밸런싱: 데이터 증가나 핫스팟 해소를 위해 샤드 간 데이터 이동. 온라인으로 수행하기 까다로움 글로벌 유니크 ID: 오토 인크리먼트가 샤드 단위이므로, 글로벌 유일성 보장을 위해 Snowflake ID, UUID, 중앙 ID 서비스 필요Replicationflowchart LR W[쓰기 트래픽] --> P["Primary/Leader"] P --> R1[Replica 1] P --> R2[Replica 2] P --> R3[Replica 3] Q[읽기 트래픽] --> R1 Q --> R2 Q --> R3복제는 동일 데이터를 여러 노드에 유지해 가용성과 읽기 처리량을 높인다.복제 유형: 동기 복제 (Synchronous): 프라이머리가 커밋 전에 최소 1개 레플리카의 확인(ACK)을 대기. 데이터 손실 없음(RPO=0). 단, 레플리카 장애 시 쓰기 지연/중단 비동기 복제 (Asynchronous): 프라이머리가 커밋하고 레플리카에 비동기 전파. 쓰기 지연 최소. 단, 프라이머리 장애 시 아직 복제되지 않은 데이터 손실 가능 (RPO > 0) 반동기 복제 (Semi-synchronous): MySQL의 semi-sync. 최소 1개 레플리카가 로그를 수신했음을 확인. 적용(apply)은 비동기복제 지연 (Replication Lag): 비동기 복제에서 프라이머리와 레플리카의 데이터 차이. 읽기가 레플리카에서 이루어지면 최근 쓰기 결과가 보이지 않을 수 있다 (eventual consistency). 해결 전략: “쓰고 자기가 읽을 때”는 프라이머리에서 읽기, 또는 causal consistency 보장 (읽기 시 최소 LSN 조건 부여).Leader-Followerflowchart TD W[쓰기] --> L[Leader] L --> F1[Follower 1] L --> F2[Follower 2] L --> F3[Follower 3] R[읽기] --> F1 R --> F2 R --> F3리더-팔로워(Primary-Replica) 구조에서는 쓰기를 리더가 담당하고 팔로워가 복제본을 유지한다.Failover 과정: 리더 장애 감지 (heartbeat 타임아웃) 새 리더 선출 (가장 최신 데이터를 가진 팔로워, 또는 별도 합의) 다른 팔로워와 클라이언트를 새 리더로 리다이렉트 구 리더 복귀 시 팔로워로 재합류Failover의 위험: 데이터 손실: 비동기 복제에서 새 리더가 구 리더의 마지막 쓰기를 받지 못했을 수 있다. 구 리더 복귀 시 충돌하는 쓰기를 어떻게 처리할 것인가? Split-Brain: 네트워크 분할로 두 노드가 동시에 리더로 동작. 양쪽에서 쓰기가 발생하면 데이터 불일치. 방지: Fencing(구 리더의 I/O를 차단), Quorum 기반 선출Multi-Leader (Multi-Master): 여러 데이터센터에 각각 리더를 두는 구성. 지역 간 쓰기 지연을 줄이지만, 쓰기 충돌 해결이 복잡하다. 충돌 해결 전략: Last-Writer-Wins(LWW), 사용자 개입, CRDT(Conflict-free Replicated Data Types).Distributed Transactionflowchart TD S1[Service A 로컬 tx] --> E1[Event] E1 --> S2[Service B 로컬 tx] S2 --> E2[Event] E2 --> S3[Service C 로컬 tx] S3 --> OK[모두 성공] S2 --> COMP["실패 시 -> compensating tx"]분산 트랜잭션은 여러 노드/서비스에 걸친 원자성을 보장하려는 시도다.분산 트랜잭션이 어려운 이유: 네트워크 분할/지연: 일부 노드의 응답을 받지 못하는 상황 부분 장애: 일부 노드만 커밋하고 나머지는 실패 합의 비용: 모든 참여자의 동의를 얻는 데 여러 RTT 필요대체 패턴: Saga Pattern: 각 서비스의 로컬 트랜잭션을 순차 실행하고, 실패 시 보상 트랜잭션(compensating transaction)을 역순 실행. 예: 주문 생성 → 결제 → 배송. 결제 실패 시 주문 취소(보상) Choreography: 이벤트 기반. 각 서비스가 이벤트를 발행하고 구독. 느슨한 결합이지만 흐름 추적이 어려움 Orchestration: 중앙 조율자가 각 단계를 지시. 흐름이 명확하지만 조율자가 SPOF Outbox Pattern: 로컬 트랜잭션으로 비즈니스 데이터와 이벤트를 같은 DB에 저장(outbox 테이블). 별도 프로세스가 outbox에서 이벤트를 읽어 메시지 브로커에 발행. 최소 1회 전달 보장2PC / 3PCsequenceDiagram participant C as Coordinator participant P1 as Participant 1 participant P2 as Participant 2 C->>P1: PREPARE? C->>P2: PREPARE? P1-->>C: YES P2-->>C: YES C->>P1: COMMIT C->>P2: COMMIT P1-->>C: ACK P2-->>C: ACK2PC (Two-Phase Commit): Prepare Phase: 코디네이터가 모든 참여자에게 “커밋 준비됐나?” 질의. 참여자는 로그를 디스크에 기록하고 “준비됨(Yes)” 또는 “거부(No)” 응답 Commit Phase: 모든 참여자가 Yes → 코디네이터가 “커밋” 결정 전파. 하나라도 No → “롤백” 결정 전파2PC의 치명적 단점: 블로킹 문제. Prepare 응답 후 코디네이터가 장애나면, 참여자는 커밋/롤백 결정을 알 수 없어 무한 대기(in-doubt 상태). 이 상태에서 자원 잠금이 유지된다.3PC (Three-Phase Commit): prepare와 commit 사이에 pre-commit 단계를 추가해 블로킹을 줄이려는 시도. 하지만 네트워크 분할 상황에서 안전성 보장이 어렵고, 구현 복잡성 때문에 실전에서 거의 사용하지 않는다.현대적 대안: Percolator (Google), Calvin (deterministic database), Spanner (TrueTime + Paxos). 이들은 2PC의 한계를 분산 합의(consensus)와 결합해 극복한다.11. NoSQLKey-Valueflowchart LR K[Key] --> H["Hash / partition"] H --> N["노드 / slot"] N --> V[Value blob] V --> O["평균 O(1) get/set/del"]키-값 저장소는 가장 단순한 모델로, 키로 값을 조회/저장한다. 복잡한 쿼리는 불가능하지만 단일 키 연산의 지연이 매우 낮다.대표 시스템: Redis: 인메모리. 문자열, 리스트, 셋, 해시, 정렬 셋 등 풍부한 자료구조. 캐시, 세션, 실시간 순위표, Pub/Sub. 단일 스레드(이벤트 루프) + I/O 멀티플렉싱 Memcached: 인메모리. 순수 키-값. 멀티스레드. Redis보다 단순하지만 멀티코어 활용에 유리 DynamoDB: 관리형 분산 KV. 일관된 지연(SLA: p99 < 10ms), 자동 확장, 글로벌 테이블Redis 내부: 데이터 구조: SDS(Simple Dynamic String), ziplist(작은 리스트/해시를 메모리 효율적으로 저장), skiplist(sorted set), intset 지속성: RDB(스냅샷, fork + CoW), AOF(Append-Only File, WAL 유사). 조합 사용 가능 클러스터: 해시 슬롯 16384개를 노드에 분배. Gossip 프로토콜로 노드 상태 관리. 클라이언트 리디렉션(-MOVED/-ASK)Documentflowchart TD D["Document JSON/BSON"] --> F1[유연한 스키마] D --> F2[중첩 필드] D --> F3[경로 기반 secondary index] F3 --> Q[필드 predicate로 조회]도큐먼트 DB는 JSON/BSON 유사 구조를 저장해 스키마 유연성이 높다.대표 시스템: MongoDB: BSON 문서. 동적 스키마. 강력한 집계 파이프라인. WiredTiger 엔진(B-Tree + MVCC + 압축). 복제셋 + 샤딩. 버전 4.0+에서 다중 문서 트랜잭션 지원 Couchbase: JSON 문서 + 내장 캐시(memcached 호환). N1QL(SQL-like 쿼리). 분산 아키텍처도큐먼트 모델링: 임베딩(Embedding): 관련 데이터를 하나의 문서에 중첩. 예: 주문 문서에 주문 항목 배열 포함. 읽기 1회로 모든 데이터를 가져옴. 문서 크기 제한(MongoDB 16MB)과 갱신 복잡성에 주의 참조(Referencing): 관련 데이터의 ID를 저장. 정규화와 유사. 읽기 시 추가 쿼리 필요 (애플리케이션 레벨 조인) 결정 기준: 1:1, 1:소수 → 임베딩. 1:다수, N:M → 참조. 함께 읽는 빈도가 높으면 → 임베딩Column Familyflowchart TD RK[Row Key] --> CF1["Column Family: profile"] RK --> CF2["Column Family: metrics"] CF1 --> C1["name,email"] CF2 --> C2["ts->value wide columns"]컬럼 패밀리 모델은 행 키(row key) + 컬럼 패밀리(column family) + 컬럼(column) + 타임스탬프로 데이터를 조직한다. 대규모 분산 쓰기/읽기에 강하다.대표 시스템: Cassandra: Masterless 아키텍처(모든 노드가 동등). Consistent Hashing + Gossip. 튜너블 일관성(ONE/QUORUM/ALL). 쓰기 최적화(LSM Tree 기반). CQL(SQL-like) HBase: HDFS 위의 분산 KV. Region Server가 행 키 범위를 담당. 강한 일관성(CP). Hadoop 에코시스템 통합Cassandra 데이터 모델링 원칙: 쿼리 퍼스트 설계: 어떤 쿼리를 실행할지 먼저 정의하고, 그에 맞게 테이블 설계. 정규화보다 중복을 허용해 단일 파티션 읽기로 쿼리를 해결 파티션 키: 데이터 분배를 결정. 핫 파티션 방지가 핵심. 파티션 크기 제한(100MB~수백MB 권장) 클러스터링 키: 파티션 내 정렬 순서 결정. 시계열 데이터에서 타임스탬프를 클러스터링 키로 설정하면 최신 데이터부터 효율적으로 읽을 수 있음Graph DBgraph LR U1((사용자 A)) -- FRIEND --> U2((사용자 B)) U2 -- FRIEND --> U3((사용자 C)) U3 -- BOUGHT --> P1((Product X)) U1 -- VIEWED --> P1그래프 DB는 노드(정점)-간선(엣지)-속성 모델로 데이터를 저장하며, 관계 탐색(graph traversal)에 특화된다.대표 시스템: Neo4j: Property Graph 모델. Cypher 쿼리 언어. ACID 트랜잭션. Index-free adjacency 기반으로 인접 관계 탐색 시 조인 비용이 작고, 그래프 순회에 유리 Amazon Neptune: 관리형. Property Graph(Gremlin) + RDF(SPARQL) 지원관계형 DB vs 그래프 DB 성능 비교: 깊이 6의 친구 탐색(소셜 네트워크): RDBMS: 재귀 조인 6회 → 수 초~수 분 Graph DB: 인접 리스트 탐색 → 밀리초 -- Cypher: 3단계 이내 친구 찾기MATCH (user:Person {name: 'Alice'})-[:FRIEND*1..3]-(friend:Person)RETURN DISTINCT friend.name-- RDBMS에서의 동등 쿼리 (재귀 CTE)WITH RECURSIVE friends AS ( SELECT friend_id, 1 as depth FROM friendships WHERE user_id = 1 UNION ALL SELECT f.friend_id, fr.depth + 1 FROM friendships f JOIN friends fr ON f.user_id = fr.friend_id WHERE fr.depth < 3)SELECT DISTINCT u.name FROM friends f JOIN users u ON f.friend_id = u.id;그래프 DB가 불리한 경우: 대규모 집계/OLAP 중심: 전체 스캔 + 대량 집계는 컬럼형/관계형 엔진이 더 유리한 경우가 많다 관계 깊이보다 정형 조인이 많은 업무: 다중 조건 조인, 복잡한 정렬/집계는 RDBMS가 더 단순하고 안정적이다 슈퍼노드(hub) 편향: 팔로워 수가 매우 큰 노드가 있으면 트래버설 폭이 급증해 지연이 튄다 분산 그래프 샤딩: 서로 다른 파티션을 자주 넘는 탐색은 네트워크 hop 비용이 커진다선택 기준: 핵심 질의가 “몇 단계 이웃 탐색”인지, 아니면 “대량 집계/리포팅”인지 먼저 분류한다 온라인 트래버설은 Graph DB, 분석 집계는 별도 OLAP 스토어로 분리하는 다중 저장소 전략을 고려한다 깊이 제한, 타임아웃, 슈퍼노드 완화(샘플링/가중치 컷오프) 같은 가드레일을 운영 정책으로 둔다When to use NoSQLflowchart TD RQ[요구사항] --> Q1{복잡한 join과 엄격한 ACID가 필요한가?} Q1 -->|Yes| RDB[RDBMS] Q1 -->|No| Q2{대규모 / 단순 access pattern인가?} Q2 -->|Key lookup| KV["Key-Value"] Q2 -->|유연한 스키마| DOC[Document] Q2 -->|대규모 쓰기 / 시계열| COL[Column Family] Q2 -->|관계 순회| GDB[Graph DB]NoSQL은 “RDB를 대체”가 아니라 “문제 특성에 맞는 선택”이다. 요구사항 적합한 저장소 복잡한 조인 + 강한 일관성 RDBMS 단순 키 조회, 초저지연 Key-Value (Redis) 유연한 스키마, 문서 기반 Document (MongoDB) 대규모 쓰기, 시계열 Column Family (Cassandra) 관계 탐색, 그래프 질의 Graph (Neo4j) 전문 검색 (Full-text) Elasticsearch 실시간 분석, 집계 ClickHouse, Druid Polyglot Persistence 예시: 주 데이터: PostgreSQL (ACID, 복잡한 쿼리) 캐시: Redis (세션, 핫 데이터) 검색: Elasticsearch (전문 검색, 로그 분석) 이벤트 스트리밍: Kafka (이벤트 소싱, 서비스 간 통신) 파일/오브젝트: S3 (이미지, 비디오, 백업)핵심은 기술 선호가 아니라 요구사항 기반 의사결정이다. 데이터 모델, 일관성 요구, 쿼리 패턴, 확장성 요구, 운영 복잡도, 팀 역량을 함께 평가해 선택해야 한다.
- CS - DataStructure CS cs datastructure CS 단권화 문서입니다.(updated on 2026-03-06) CS 단권화 문서입니다.(updated on 2026-03-06)1. Complexity TheoryTime Complexitygraph LR C1["O(1)"] --> C2["O(log n)"] --> C3["O(n)"] --> C4["O(n log n)"] --> C5["O(n^2)"] --> C6["O(2^n)"] --> C7["O(n!)"]시간 복잡도는 입력 크기 n이 증가할 때 알고리즘 실행 시간이 어떻게 증가하는지를 나타낸다. 절대 시간(초)보다 증가율에 초점을 맞추기 때문에, 하드웨어나 언어 차이를 넘어 알고리즘 효율을 비교할 수 있다.$O(1) < O(\log n) < O(\sqrt{n}) < O(n) < O(n \log n) < O(n^2) < O(n^3) < O(2^n) < O(n!)$ 복잡도 n=10⁶ 연산 수 1초 내 처리 가능한 n (≈10⁸ 연산/초 기준) O(n) 10⁶ ~10⁸ O(n log n) ~2×10⁷ ~5×10⁶ O(n²) 10¹² ❌ ~10⁴ O(2ⁿ) 천문학적 ❌ ~25 같은 Big-O라도 상수 계수와 캐시 효율이 실제 성능을 좌우한다. 예를 들어 배열 순차 탐색과 연결 리스트 순차 탐색은 둘 다 O(n)이지만, 배열이 캐시 라인 프리페치 덕분에 수 배 빠를 수 있다. 알고리즘 선택 시 복잡도만이 아니라 입력 크기 범위, 데이터 분포, 하드웨어 특성을 함께 고려해야 한다.재귀 알고리즘의 시간 복잡도는 Master Theorem으로 분석할 수 있다:$T(n) = aT(n/b) + O(n^d)$ $\log_b a < d$: $T(n) = O(n^d)$ $\log_b a = d$: $T(n) = O(n^d \log n)$ $\log_b a > d$: $T(n) = O(n^{\log_b a})$예: 병합 정렬 $T(n) = 2T(n/2) + O(n)$ → $a=2, b=2, d=1$ → $\log_2 2 = 1 = d$ → $O(n \log n)$Space Complexityflowchart TD Input[입력 데이터] --> Aux[보조 공간] Aux --> Stack[재귀 스택] Aux --> Temp[임시 버퍼] Aux --> Table[DP 테이블]공간 복잡도는 알고리즘이 추가로 사용하는 메모리 양의 증가율이다. 입력 저장 자체를 제외하고 보조 메모리(auxiliary space)를 따로 보는 경우가 많다. 시간 최적화와 공간 최적화는 종종 트레이드오프 관계다.공간 복잡도 분류: In-place (O(1) auxiliary): 힙 정렬, 퀵 정렬(재귀 스택 제외). 추가 메모리 사용을 최소화 O(n) auxiliary: 병합 정렬(보조 배열), DFS(재귀 스택 최악), BFS(큐) O(n²): 인접 행렬, 동적 프로그래밍 2D 테이블재귀 알고리즘에서 재귀 깊이 자체가 공간 복잡도에 영향을 준다. 예를 들어 불균형 BST에서 재귀 탐색은 O(n) 스택 공간을 사용할 수 있고, 극단적인 경우 스택 오버플로우를 일으킨다. 이를 방지하기 위해 반복(iterative) 변환이나 꼬리 재귀 최적화(TCO, 일부 언어만 지원)를 적용한다.실무에서는 메모리 할당/해제 패턴도 성능에 영향을 준다. 잦은 소규모 할당은 메모리 단편화(fragmentation)를 유발하고, 할당자(allocator)에 따라 상당한 오버헤드가 발생할 수 있다. 이 때문에 아레나 할당(arena allocation)이나 오브젝트 풀(object pool) 패턴을 사용하기도 한다.Big-O / Theta / Omegaflowchart LR F["f(n)"] --> O["O(g(n)) 상한"] F --> T["Theta(g(n)) 타이트한 경계"] F --> W["Omega(g(n)) 하한"] Big-O ($O$): 상한(upper bound). $f(n) = O(g(n))$이면 충분히 큰 n에 대해 $f(n) \leq c \cdot g(n)$. “이 알고리즘은 최악에도 이 정도 이하” Theta ($\Theta$): 정확한 점근적 경계. $f(n) = \Theta(g(n))$이면 $c_1 \cdot g(n) \leq f(n) \leq c_2 \cdot g(n)$. 상한과 하한이 같은 차수 Omega ($\Omega$): 하한(lower bound). $f(n) = \Omega(g(n))$이면 $f(n) \geq c \cdot g(n)$. “최소한 이 정도는 걸린다”실무와 면접에서는 Big-O를 가장 많이 쓰지만, 정밀한 논의에서는 구분이 중요하다: “배열 인덱스 접근은 $\Theta(1)$” — 항상 상수 시간 “비교 기반 정렬의 하한은 $\Omega(n \log n)$” — 어떤 알고리즘이든 이보다 빠를 수 없음 “퀵 정렬의 시간 복잡도는 $O(n^2)$이지만 평균은 $\Theta(n \log n)$” — 최악과 평균이 다름소문자 표기(little-o, little-omega)도 있다: $o(g(n))$은 엄밀한 상한(=보다 작은), $\omega(g(n))$은 엄밀한 하한이다. $2n = o(n^2)$이지만 $2n \neq o(n)$.Amortized Analysisflowchart LR A[push 연산들] --> B{용량이 가득 찼나?} B -->|No| C["append O(1)"] B -->|Yes| D[더 큰 배열 할당] D --> E[n개 원소 복사] E --> C C --> F["전체 평균: 상환 O(1)"]분할 상환 분석은 “평균적인 시퀀스 비용”을 다루는 기법이다. 개별 연산 중 비싼 연산이 있어도, 긴 연산열 전체로 보면 연산당 평균 비용이 낮을 수 있다. 이는 확률적 평균(average-case)과는 다른 개념으로, 최악 시퀀스에서도 보장되는 연산당 비용이다.분석 기법: Aggregate Method: 전체 n회 연산의 총 비용 $T(n)$을 구하고, 연산당 비용 = $T(n)/n$ Accounting Method: 각 연산에 “요금”을 부과. 싼 연산에 여분을 저축해두고, 비싼 연산이 발생하면 저축에서 지불 Potential Method: 자료구조에 “잠재 에너지” 함수 $\Phi$를 정의. 상환 비용 = 실제 비용 + $\Delta\Phi$. 잠재 에너지가 증가하면 미래 비싼 연산에 대비하는 것대표 사례: 동적 배열(vector) push_back: 용량 초과 시 2배 확장 + 전체 복사 O(n). 하지만 n번 push에 총 비용 O(n) → 상환 O(1). 확장 비율이 2배가 아니라 1.5배면? 여전히 상환 O(1)이지만 복사 횟수와 메모리 낭비 비율이 달라진다 Splay Tree: 개별 연산이 O(n)일 수 있지만, m번 연산의 총 비용은 O(m log n) → 상환 O(log n) Union-Find: 경로 압축 + 랭크 합치기 시 m번 연산의 총 비용 O(m · α(n)), α는 역 아커만 함수 (사실상 상수)2. Linear StructuresArrayflowchart LR subgraph Contiguous_Memory A0[idx0] A1[idx1] A2[idx2] A3[idx3] A4[idx4] end IDX["base + i * element_size 주소"] --> A3배열은 연속 메모리에 동일 타입 원소를 저장하는 구조다. 인덱스 접근은 O(1)로 매우 빠르지만, 중간 삽입/삭제는 원소 이동 때문에 O(n)이 된다. 연산 복잡도 비고 인덱스 접근 O(1) base + index × element_size 끝 삽입/삭제 O(1) amortized 동적 배열 기준 중간 삽입/삭제 O(n) 원소 shift 필요 탐색 (정렬 안됨) O(n) 선형 탐색 탐색 (정렬됨) O(log n) 이진 탐색 캐시 지역성(Cache Locality): 배열이 연결 리스트보다 실전 성능이 좋은 핵심 이유. CPU 캐시는 메모리를 캐시 라인(보통 64B) 단위로 가져온다. 배열의 연속 원소는 같은 캐시 라인에 존재할 확률이 높아 cache hit rate가 높다. 반면 연결 리스트 노드는 메모리 전체에 흩어져 있어 노드 접근마다 cache miss가 발생할 수 있다.동적 배열 확장 전략: 2배 확장: 가장 일반적. 상환 O(1) push. 최악의 경우 50% 메모리 낭비 1.5배 확장: 메모리 낭비 감소, 복사 빈도 약간 증가. MSVC의 std::vector가 채택 증분 확장 (고정 크기 추가): 상환 O(n). 비효율적이므로 거의 사용하지 않음정렬된 배열 + 이진 탐색: 정적 데이터에 대한 효율적 검색 구조. BST보다 메모리 효율과 캐시 효율이 좋다. 삽입/삭제가 드문 조회 중심 워크로드에 최적.Linked Listflowchart LR H["Head(머리)"] --> N1["노드 1<br/>data,next"] N1 --> N2["노드 2<br/>data,next"] N2 --> N3["노드 3<br/>data,next"] N3 --> NIL[null]연결 리스트는 노드가 포인터로 이어진 구조다. 중간 삽입/삭제가 포인터 변경만으로 가능해 구조 변경에 유리하다(탐색 위치를 이미 알고 있을 때). 하지만 임의 접근이 O(n)이고 캐시 효율이 낮아 실제 성능은 배열보다 불리할 때가 많다.연결 리스트 유형: 단일 연결 리스트(Singly Linked List): 각 노드가 next 포인터만 보유. 역방향 순회 불가 이중 연결 리스트(Doubly Linked List): prev/next 포인터. 양방향 순회 가능. LRU 캐시, OS 프로세스 리스트에 활용 원형 연결 리스트(Circular Linked List): 마지막 노드가 첫 노드를 가리킴. 라운드 로빈 스케줄링에 활용실전 활용 패턴: Sentinel Node (Dummy Head/Tail): 빈 리스트/경계 조건 처리를 단순화하는 기법. 삽입/삭제 코드에서 null 체크를 제거한다 Floyd’s Cycle Detection(토끼와 거북이): 두 포인터(slow=1칸, fast=2칸)로 사이클 존재 여부를 O(n) 시간, O(1) 공간으로 판별. 사이클 시작점도 구할 수 있어 메모리 누수 탐지에도 응용 XOR 연결 리스트: prev⊕next를 하나의 포인터에 저장해 메모리를 절약하는 기법. 실전에서는 거의 사용하지 않지만 이론적으로 흥미로움현대 실무에서 연결 리스트 사용이 줄어드는 이유: 캐시 비효율성, 메모리 할당 오버헤드(노드마다 malloc), 포인터 크기(64비트 시스템에서 8B)로 인한 메모리 오버헤드. Bjarne Stroustrup은 “대부분의 경우 std::vector가 std::list보다 빠르다”고 강조한다.Stackflowchart TD P1["push(1)"] --> S1["top(맨 위) → 1"] P2["push(2)"] --> S2["top(맨 위) → 2, 1"] P3["push(3)"] --> S3["top(맨 위) → 3, 2, 1"] POP["pop() → 3"] --> S4["top(맨 위) → 2, 1"]스택은 LIFO(Last-In, First-Out) 구조다. push/pop/top이 O(1)이며, 함수 호출 스택, DFS, 괄호 매칭, 되돌리기(undo) 등에 활용된다.스택의 핵심 응용: 함수 호출 스택 (Call Stack): 함수 호출 시 반환 주소, 로컬 변수, 매개변수를 스택 프레임에 저장. 재귀가 깊으면 스택 오버플로우 발생 (기본 스레드 스택: 보통 1~8MB) 괄호/수식 검증: ({[]})같은 중첩 구조 검증. 여는 괄호를 push하고, 닫는 괄호를 만나면 pop해서 매칭 확인 후위 표기법(Postfix) 계산: 3 4 + 5 * → 피연산자를 push, 연산자를 만나면 두 개 pop해서 계산 후 결과 push 중위→후위 변환 (Shunting-Yard Algorithm): 연산자 우선순위를 고려해 중위 표기법을 후위로 변환 모노톤 스택(Monotonic Stack): 원소가 단조 증가/감소하도록 유지하는 스택. Next Greater Element, 히스토그램 최대 넓이, 주가 스팬 문제에 활용. O(n)으로 해결 가능 (각 원소가 최대 한 번 push/pop)// Monotonic Stack: 각 원소의 Next Greater Element 찾기public static int[] nextGreater(int[] nums) { int[] result = new int[nums.length]; Arrays.fill(result, -1); Deque<Integer> stack = new ArrayDeque<>(); for (int i = 0; i < nums.length; i++) { while (!stack.isEmpty() && nums[stack.peek()] < nums[i]) { result[stack.pop()] = nums[i]; } stack.push(i); } return result;}// [2, 1, 4, 3, 5] → [4, 4, 5, 5, -1]Queueflowchart LR subgraph Circular_Buffer S0["slot 0: A"] --> S1["slot 1: B"] --> S2["slot 2: C"] --> S3["slot 3: ·"] --> S4["slot 4: ·"] S4 --> S0 end ENQ["enqueue(D)"] -.-> S3 S0 -.-> DEQ["dequeue() → A"] HEAD["head(앞)"] -->|index 0| S0 TAIL["tail(뒤)"] -->|index 3| S3큐는 FIFO(First-In, First-Out) 구조다. enqueue/dequeue가 기본이며 작업 스케줄링, BFS, 버퍼링 시스템에 널리 사용된다.큐 구현 방식: 배열 기반 원형 큐(Circular Buffer): head/tail 인덱스로 wrap-around. 메모리 효율적이고 캐시 친화적. 고정 크기 연결 리스트 기반: 동적 크기. 노드 할당 오버헤드 존재 Lock-Free Queue (Michael-Scott Queue): CAS(Compare-And-Swap) 기반 동시성 큐. 뮤텍스 없이 다중 생산자/소비자 안전큐의 변형: Circular Queue: 배열에서 앞쪽 공간 낭비를 해결. head가 끝에 도달하면 다시 앞으로 Priority Queue: 단순 FIFO가 아닌 우선순위 기반. 힙으로 구현 (별도 섹션) Blocking Queue: 비어있으면 dequeue를 블로킹, 가득 차면 enqueue를 블로킹. 생산자-소비자 패턴의 핵심고성능 환경에서는 SPSC(Single Producer Single Consumer) Ring Buffer가 가장 효율적이다. Disruptor(LMAX)는 CAS 없이도 고성능 메시지 전달을 달성하는 ring buffer 기반 구조로, 초당 수백만 메시지를 처리한다.Dequeflowchart LR FL[push_front] --> FRONT FRONT["front(앞쪽)"] --> DEQ["A | B | C | D"] DEQ --> BACK["back(뒤쪽)"] BACK --> RL[push_back] FR[pop_front] -.-> FRONT RR[pop_back] -.-> BACK덱(deque, Double-Ended Queue)은 양끝 삽입/삭제를 모두 O(1)로 지원하는 구조다.구현: C++ std::deque는 고정 크기 블록 배열의 배열(map of blocks)로 구현된다. 앞/뒤 확장이 모두 O(1) amortized이면서 인덱스 접근도 O(1)을 제공한다. Python의 collections.deque는 이중 연결 블록 리스트로 구현되며, 양끝 O(1) 보장하지만 인덱스 접근은 O(n)이다.알고리즘 활용: 슬라이딩 윈도우 최댓값/최솟값: 덱에 모노톤 순서를 유지하며 윈도우의 최댓값/최솟값을 O(1)에 조회. 전체 O(n)// 슬라이딩 윈도우 최댓값 (크기 k)public static int[] slidingMax(int[] nums, int k) { Deque<Integer> dq = new ArrayDeque<>(); // 인덱스를 저장 (값은 감소 순으로 유지) int[] result = new int[nums.length - k + 1]; int idx = 0; for (int i = 0; i < nums.length; i++) { while (!dq.isEmpty() && nums[dq.peekLast()] <= nums[i]) { dq.pollLast(); // 뒤에서 작은 값 제거 } dq.addLast(i); if (dq.peekFirst() <= i - k) { dq.pollFirst(); // 윈도우 범위 밖 제거 } if (i >= k - 1) { result[idx++] = nums[dq.peekFirst()]; } } return result;} 0-1 BFS: 간선 가중치가 0 또는 1인 그래프에서 최단 경로. 가중치 0이면 앞에 push, 1이면 뒤에 push. Dijkstra 대신 O(V+E)에 해결 Palindrome 검사: 양끝에서 비교하며 진행 Steal-Based Work Scheduling: Java ForkJoinPool에서 각 스레드가 자신의 deque를 사용해 작업을 push/pop하고, 유휴 스레드가 다른 스레드의 deque 반대쪽에서 작업을 “steal”3. TreeBinary Treegraph TD A((A)) --> B((B)) A --> C((C)) B --> D((D)) B --> E((E)) C --> F((F)) C --> G((G))이진 트리는 각 노드가 최대 두 자식을 갖는 계층 구조다.이진 트리의 종류: Full Binary Tree: 모든 노드가 0 또는 2개의 자식을 가짐 Complete Binary Tree: 마지막 레벨을 제외한 모든 레벨이 완전히 채워지고, 마지막 레벨은 왼쪽부터 채워짐. 배열로 효율적 표현 가능 (힙에 사용) Perfect Binary Tree: 모든 내부 노드가 2개 자식을 갖고 모든 리프가 같은 깊이. 높이 h에서 노드 수 = $2^{h+1} - 1$ Degenerate (Skewed) Tree: 모든 노드가 하나의 자식만 가짐. 사실상 연결 리스트. BST가 정렬된 데이터로 구성되면 발생순회(Traversal): 전위(Preorder): 루트 → 왼쪽 → 오른쪽. 트리 직렬화/복사에 활용 중위(Inorder): 왼쪽 → 루트 → 오른쪽. BST에서 정렬된 순서 출력 후위(Postorder): 왼쪽 → 오른쪽 → 루트. 트리 삭제, 수식 트리 계산에 활용 레벨순(Level-order/BFS): 큐 기반. 같은 깊이 노드를 먼저 방문트리 속성 공식: 높이 h인 이진 트리의 최대 노드 수: $2^{h+1} - 1$ n개 노드의 이진 트리 최소 높이: $\lfloor \log_2 n \rfloor$ n개 노드의 이진 트리 중 다른 구조 수 (Catalan 수): $C_n = \frac{1}{n+1}\binom{2n}{n}$Morris Traversal: 스택/재귀 없이 O(1) 공간으로 중위 순회를 수행하는 기법. 스레디드(threaded) 포인터 개념을 활용해 우측 null 포인터를 임시로 부모를 가리키게 만든 후 복원한다. 제약이 있는 임베디드 환경에서 유용하다.Binary Search Treegraph TD R((8)) --> L((3)) R --> X((10)) L --> A((1)) L --> B((6)) X --> C((14)) B --> D((4)) B --> E((7)) C --> F((13))BST는 “왼쪽 < 루트 < 오른쪽” 정렬 성질을 가진 이진 트리다. 평균적으로 탐색/삽입/삭제 O(log n)이지만 편향되면 O(n)으로 악화된다.BST 핵심 연산: 삽입: 루트부터 비교하며 내려가 빈 위치에 새 노드 추가 삭제: 세 가지 경우 리프 노드: 바로 제거 자식 1개: 자식으로 교체 자식 2개: 중위 후속자(inorder successor, 오른쪽 서브트리의 최솟값)로 교체 후 후속자 삭제 탐색: 비교 결과에 따라 왼쪽/오른쪽으로 내려감BST의 한계와 해결책: 정렬된 데이터를 순서대로 삽입하면 편향 트리(높이 = n) → O(n) 성능 랜덤화 BST (Treap): 각 노드에 랜덤 우선순위를 부여해 확률적으로 균형 유지. BST 성질(키) + Heap 성질(우선순위) Splay Tree: 접근한 노드를 회전으로 루트까지 올림(splaying). Temporal locality가 높은 워크로드에서 우수한 상환 O(log n) 성능. 캐시에 적합AVL Treeflowchart TD A[균형이 깨진 노드] --> B{Case(유형)} B -->|LL| C[오른쪽 회전] B -->|RR| D[왼쪽 회전] B -->|LR| E[자식에서 왼쪽 회전<br/>후 오른쪽 회전] B -->|RL| F[자식에서 오른쪽 회전<br/>후 왼쪽 회전] C --> G[균형 잡힌 서브트리] D --> G E --> G F --> GAVL 트리는 높이 균형 조건(좌우 서브트리 높이 차이 ≤ 1)을 엄격히 유지하는 자기 균형 BST다. 균형 인수(Balance Factor) = 왼쪽 서브트리 높이 - 오른쪽 서브트리 높이. BF ≤ 1을 항상 유지한다. 회전 연산 (불균형 복구): LL (Left-Left): Right Rotation. 왼쪽 자식의 왼쪽에 삽입으로 불균형 RR (Right-Right): Left Rotation. 오른쪽 자식의 오른쪽에 삽입 LR (Left-Right): Left Rotation on child → Right Rotation on node. 왼쪽 자식의 오른쪽에 삽입 RL (Right-Left): Right Rotation on child → Left Rotation on node 30 (BF=2) 20 / → / \ 20 10 30 / 10LL Case: Right RotationAVL 트리의 높이는 최대 $1.44 \log_2(n+2)$으로 보장된다. 이는 Red-Black Tree의 $2 \log_2(n+1)$보다 엄격한 균형을 의미한다. 따라서 메모리 내에서 탐색 비중이 높은 ordered map/set 워크로드에서는 AVL이 유리할 수 있다. 반면 삽입/삭제가 잦으면 회전 빈도 때문에 RB Tree가 유리할 수 있다.Red-Black Treegraph TD R((10 B)) --> L((5 R)) R --> X((15 R)) L --> A((2 B)) L --> B((7 B)) X --> C((12 B)) X --> D((20 B))Red-Black 트리는 색상 규칙으로 균형을 완화해 유지하는 자기 균형 BST다. AVL보다 균형은 덜 엄격하지만 갱신 연산에 유리한 경우가 많다.Red-Black Tree의 5가지 규칙: 모든 노드는 빨강 또는 검정 루트는 검정 모든 NIL(리프)은 검정 빨간 노드의 자식은 모두 검정 (Red 연속 불가) 루트에서 모든 NIL까지의 경로에 있는 검정 노드 수(Black Height)가 동일이 규칙들에 의해 최장 경로는 최단 경로의 2배를 넘지 않는다 (최단 = 모두 검정, 최장 = 검정-빨강 교대).삽입/삭제 후 규칙 위반 시 회전 + 색상 변경으로 복구한다: 삽입: 새 노드를 빨강으로 삽입 → 부모가 빨강이면 위반 → 삼촌(uncle) 색상에 따라 재색칠 또는 회전 삭제: 검정 노드 삭제 시 Black Height 불균형 → 형제(sibling) 색상/자식에 따라 회전 + 재색칠실전 채택: C++ std::map/std::set: 대부분의 구현이 RB Tree Java TreeMap/TreeSet: RB Tree Linux CFS 스케줄러: 프로세스의 vruntime을 RB Tree에 저장 Java 8+ HashMap: 충돌 체인이 길어지고 테이블 크기도 충분히 크면 버킷을 RB Tree로 treeify해 최악 탐색을 O(log n)으로 낮춘다Segment Treegraph TD N1["1: 0..7"] --> N2["2: 0..3"] N1 --> N3["3: 4..7"] N2 --> N4["4: 0..1"] N2 --> N5["5: 2..3"] N3 --> N6["6: 4..5"] N3 --> N7["7: 6..7"]세그먼트 트리는 구간 질의(합, 최소, 최대 등)와 점/구간 업데이트를 효율적으로 처리하는 트리이다.구조: 완전 이진 트리 형태. 리프 노드는 원본 배열의 각 원소, 내부 노드는 자식들의 합/최소/최대 등을 저장한다. 연산 복잡도 빌드 O(n) 점 업데이트 O(log n) 구간 질의 O(log n) 구간 업데이트 (Lazy) O(log n) 공간 O(n) (4n 배열) Lazy Propagation: 구간 업데이트를 즉시 전파하지 않고 “나중에 필요할 때” 전파하는 기법. “구간 [l, r]에 모두 +5”를 할 때, 해당 구간의 노드에 lazy 값을 저장해두고, 이후 해당 노드의 자식을 방문할 때 비로소 전파한다. 이를 통해 구간 업데이트가 O(n) → O(log n)이 된다.// 구간 합 세그먼트 트리 (1-indexed)static void build(int[] arr, int[] tree, int node, int start, int end) { if (start == end) { tree[node] = arr[start]; } else { int mid = (start + end) / 2; build(arr, tree, 2 * node, start, mid); build(arr, tree, 2 * node + 1, mid + 1, end); tree[node] = tree[2 * node] + tree[2 * node + 1]; }}static int query(int[] tree, int node, int start, int end, int l, int r) { if (r < start || end < l) // 범위 밖 return 0; if (l <= start && end <= r) // 완전히 포함 return tree[node]; int mid = (start + end) / 2; return query(tree, 2 * node, start, mid, l, r) + query(tree, 2 * node + 1, mid + 1, end, l, r);}변형: Persistent Segment Tree (이전 버전의 트리를 유지), Merge Sort Tree (각 노드에 정렬된 배열 저장), 2D Segment Tree (2차원 구간 질의).Fenwick Treeflowchart TD U["update(i, delta)"] --> A["i = i + (i & -i)"] A --> B["BIT(i)에 delta 더하기"] B --> C{N 이내인가?} C -->|Yes| A C -->|No| END1[완료] Q["prefixSum(i)"] --> D["i = i - (i & -i)"] D --> E["BIT(i) 누적"] E --> F{i > 0?} F -->|Yes| D F -->|No| END2[result]Fenwick Tree(Binary Indexed Tree, BIT)는 누적 합 기반의 점 업데이트 + prefix sum 질의를 O(log n)에 처리하는 구조다. 구현이 세그먼트 트리보다 간결하고 메모리 효율이 좋다.핵심 원리: 인덱스의 이진 표현에서 최하위 비트(LSB)가 해당 노드의 관리 범위를 결정한다. i & -i (또는 i & (~i + 1))로 LSB를 추출 업데이트: 인덱스에서 LSB를 더하며 상위로 이동 질의: 인덱스에서 LSB를 빼며 누적// 1-indexed Fenwick Treeclass BIT { int[] tree; int n; BIT(int n) { this.n = n; tree = new int[n + 1]; } void update(int i, int delta) { for (; i <= n; i += i & -i) tree[i] += delta; } int query(int i) { // prefix sum [1..i] int sum = 0; for (; i > 0; i -= i & -i) sum += tree[i]; return sum; } int rangeQuery(int l, int r) { // sum [l..r] return query(r) - query(l - 1); }}세그먼트 트리 vs Fenwick Tree: Fenwick: 코드가 짧고, 상수가 작고, 메모리가 절반. 단, 원래는 prefix sum에 최적화되어 있고, min/max 등 비가역 연산에는 직접 적용 어려움 Segment Tree: 더 범용적. 구간 업데이트(Lazy), min/max, 좌표 압축 세그 등 확장이 자유로움2D Fenwick Tree: 2차원 prefix sum을 O(log n · log m)에 질의/업데이트. 2D 격자에서 직사각형 영역 합 계산에 활용.4. HeapBinary Heapgraph TD H1((1)) --> H2((3)) H1 --> H3((6)) H2 --> H4((5)) H2 --> H5((9)) H3 --> H6((8)) H3 --> H7((10))이진 힙은 완전 이진 트리 기반 우선순위 구조다. 보통 배열로 구현하며 부모/자식 인덱스 관계로 빠르게 탐색한다.배열 인덱싱 (0-based): 부모: (i - 1) / 2 왼쪽 자식: 2i + 1 오른쪽 자식: 2i + 2최소 힙 성질: 부모 ≤ 자식 (모든 노드에서). 루트가 항상 최솟값.핵심 연산: Sift-Up (Bubble Up): 삽입 시 배열 끝에 추가 후, 부모보다 작으면 교환하며 올라감. O(log n) Sift-Down (Bubble Down): 최솟값 삭제(루트 제거) 후, 마지막 원소를 루트에 놓고 자식 중 작은 쪽과 교환하며 내려감. O(log n) Peek: 루트 반환. O(1) 힙 유형 insert delete-min decrease-key merge Binary Heap O(log n) O(log n) O(log n) O(n) Fibonacci Heap O(1)* O(log n)* O(1)* O(1) Binomial Heap O(log n) O(log n) O(log n) O(log n) Pairing Heap O(1) O(log n)* O(log n)* O(1) (*: amortized)Fibonacci Heap: decrease-key가 상환 O(1)이라 Dijkstra, Prim에서 이론적 최적. 하지만 상수가 크고 구현이 복잡해 실전에서는 이진 힙이 더 빠른 경우가 많다.Priority Queueflowchart TD IN["insert(x)"] --> HEAP[Binary Heap] TOP["peek()"] --> HEAP POP["extract-min/max"] --> HEAP HEAP --> ORD[우선순위 순서 유지]우선순위 큐는 “가장 우선순위 높은 원소”를 빠르게 꺼내는 ADT(Abstract Data Type)다. 힙 기반 구현에서 삽입/삭제가 O(log n), 최상단 조회가 O(1)이다.활용 사례: Dijkstra 최단 경로: 탐색할 노드를 거리 기준으로 우선순위 큐에 저장. min-heap으로 가장 가까운 노드를 먼저 확정 Prim MST: 간선 가중치 기준 min-heap으로 최소 비용 간선 선택 Huffman Coding: 빈도수가 낮은 노드 두 개를 반복적으로 병합. min-heap 활용 OS 프로세스/스레드 스케줄링: Priority Queue 기반 우선순위 스케줄러 이벤트 기반 시뮬레이션: 타임스탬프 기준 min-heap으로 다음 이벤트 결정 K-way Merge: K개의 정렬된 리스트를 병합할 때, 각 리스트의 현재 원소를 min-heap에 넣고 하나씩 추출. O(N log K) Top-K 문제: max-heap(전체)보다 크기 K의 min-heap을 유지하는 것이 효율적. 새 원소가 힙의 최솟값보다 크면 교체. O(N log K)// Java의 PriorityQueue는 min-heap// max-heap으로 사용하려면 Collections.reverseOrder() 전달PriorityQueue<Integer> minHeap = new PriorityQueue<>();PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Collections.reverseOrder());// K개 정렬 리스트 병합public static List<Integer> mergeKSorted(List<List<Integer>> lists) { // {값, 리스트 인덱스, 원소 인덱스} PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> a[0] - b[0]); for (int i = 0; i < lists.size(); i++) { if (!lists.get(i).isEmpty()) { pq.offer(new int[]{lists.get(i).get(0), i, 0}); } } List<Integer> result = new ArrayList<>(); while (!pq.isEmpty()) { int[] cur = pq.poll(); int val = cur[0], listIdx = cur[1], elemIdx = cur[2]; result.add(val); if (elemIdx + 1 < lists.get(listIdx).size()) { pq.offer(new int[]{lists.get(listIdx).get(elemIdx + 1), listIdx, elemIdx + 1}); } } return result;}Heapifyflowchart TD A["마지막 non-leaf = n/2-1에서 시작"] --> B["node i를 sift-down"] B --> C{i > 0?} C -->|Yes| D["i = i-1"] D --> B C -->|No| E["Heap을 O(n)에 구축"]Heapify는 배열을 힙 성질로 변환하는 과정이다.Bottom-Up Heapify가 O(n)인 이유: 높이 h에서 노드 수: ~n/2^(h+1) 각 노드의 sift-down 비용: O(h) 총 비용: $\sum_{h=0}^{\log n} \frac{n}{2^{h+1}} \cdot h = O(n)$ 리프 노드(절반)는 sift-down 불필요, 루트 근처의 소수 노드만 많이 내려감Top-Down 방식(n번 insert)은 O(n log n)이므로 Bottom-Up이 중요하다.힙 정렬(Heap Sort) 과정: 배열을 max-heap으로 변환: O(n) (bottom-up heapify) 루트(최댓값)를 배열 끝과 교환하고 힙 크기를 줄인 후 sift-down: 반복 n-1번, 각 O(log n) 총 시간: O(n log n), 공간: O(1) in-place힙 정렬의 특성: 최악에도 O(n log n) 보장(퀵 정렬과 달리), in-place(병합 정렬과 달리). 하지만 캐시 효율이 떨어져(배열 내 비연속적 접근 패턴) 실전에서 퀵 정렬보다 느린 경우가 많다. 이 때문에 정렬 라이브러리는 보통 Introsort(퀵 정렬 + 힙 정렬 폴백)를 사용한다.5. HashHash Tableflowchart TD K[Key] --> H[Hash 함수] H --> B[Bucket i] B --> C{충돌 발생?} C -->|No| S1[바로 저장] C -->|Chaining| S2[버킷 안 linked list 또는 tree] C -->|Open Addressing| S3[다음 slot 탐사]해시 테이블은 키를 해시 함수로 인덱스로 매핑해 평균 O(1) 조회/삽입/삭제를 제공한다.좋은 해시 함수의 조건: 균일 분포(Uniform Distribution): 키가 모든 버킷에 고르게 매핑 빠른 계산: 해시 계산 자체가 병목이 되면 안 됨 결정론적(Deterministic): 같은 입력에 항상 같은 값 눈사태 효과(Avalanche Effect): 입력이 1비트 변해도 출력이 크게 변함실전 해시 함수: 정수: h(k) = k mod m (m은 소수가 좋음). 또는 곱셈 해시: $h(k) = \lfloor m(kA \mod 1) \rfloor$, A ≈ 0.6180339887 (황금비의 역수) 문자열: 다항식 해시 h = s[0]·p^(n-1) + s[1]·p^(n-2) + ... + s[n-1]. Rabin-Karp 패턴 매칭에 활용 범용 해시: MurmurHash3, xxHash (비암호학적, 매우 빠름), SipHash (HashDoS 방어, Python/Rust 기본)Collision Resolutionflowchart LR K[Key] --> H[Hash] H --> B[Bucket i 점유됨] B --> C{전략 선택} C --> CH["Chaining: linked list/tree"] C --> OA[Open addressing] OA --> LP[Linear probing] OA --> QP[Quadratic probing] OA --> DH[Double hashing]충돌 해결은 해시 품질 못지않게 중요하다. 대표 방식은 체이닝과 개방 주소법이며, 워크로드 특성(삭제 빈도, 메모리 locality, load factor)에 따라 선택이 달라진다.Chaining체이닝은 각 버킷에 연결 리스트(또는 동적 구조)를 두어 충돌 키를 저장한다. 장점: 구현 간단, 삭제 용이, load factor > 1 허용 단점: 포인터 오버헤드, 캐시 비효율 최적화: Java 8 HashMap은 충돌 체인이 길고 테이블 용량도 충분할 때 Red-Black Tree로 treeify해 최악 탐색 비용을 낮춘다Open Addressing개방 주소법은 충돌 시 테이블 내부의 다른 빈 슬롯을 탐사한다. Linear Probing: $h(k, i) = (h(k) + i) \mod m$. 캐시 효율 최고. 단, Primary Clustering 문제 (연속된 점유 구간이 커져 탐사 길이 증가) Quadratic Probing: $h(k, i) = (h(k) + c_1 i + c_2 i^2) \mod m$. Primary Clustering 완화. 단, Secondary Clustering (같은 해시값은 같은 탐사 서열) Double Hashing: $h(k, i) = (h_1(k) + i \cdot h_2(k)) \mod m$. 두 번째 해시로 탐사 간격 결정. 클러스터링 최소화삭제 문제: Open Addressing에서 단순 삭제하면 탐사 체인이 끊어진다. 해결책: Tombstone(삭제 표시)을 남기고, 탐사 시 건너뛰며 진행. 하지만 tombstone이 많아지면 성능 저하 → 주기적 rehashing 필요.Robin Hood Hashing: 탐사 거리가 긴 키가 짧은 키의 자리를 “빼앗는” 방식. 탐사 거리의 분산을 줄여 최악 케이스를 개선한다. Rust의 HashMap이 이 방식을 사용했다 (이후 SwissTable로 변경).Cuckoo Hashing: 두 개의 해시 함수와 두 개의 테이블을 사용. 삽입 시 충돌하면 기존 키를 다른 테이블로 이동시킴. 최악 O(1) 조회를 보장하지만, 삽입이 실패할 수 있어 rehashing이 필요할 수 있다.Load Factorflowchart LR N[저장된 엔트리 수 n] --> LF["alpha = n / bucket_count"] B[버킷 수 m] --> LF LF --> PERF["충돌 확률 / 성능"]로드 팩터(α = n/m, n=원소 수, m=버킷 수)는 성능의 핵심 지표다.Chaining: 평균 탐사 길이 = 1 + α/2 (성공), 1 + α (실패). α > 1도 동작하지만 성능 저하Open Addressing: 1/(1-α)에 비례. α가 1에 가까워질수록 급격히 악화 α Linear Probing 평균 탐사 (성공) Chaining 평균 탐사 (성공) 0.5 ~1.5 ~1.25 0.75 ~2.5 ~1.375 0.9 ~5.5 ~1.45 일반적인 리사이즈 임계치: Open Addressing 0.5~0.75, Chaining 0.75~1.0. Google의 Swiss Table(Abseil)은 load factor 0.875에서도 효율적으로 동작하도록 SIMD 기반 그룹 탐사를 사용한다.Rehashingflowchart TD A[삽입으로 load factor가 커짐] --> B[더 큰 테이블 할당] B --> C[각 key의 hash 재계산] C --> D[새 버킷으로 엔트리 이동] D --> E[Swap new table]리해싱은 테이블 크기를 확장하고 기존 원소를 새 해시 정책에 따라 재배치하는 과정이다.리해싱 전략: 전체 리해싱: 새 테이블 할당 → 모든 원소를 새 해시로 재삽입. O(n) 비용이지만 상환 O(1). 동적 배열 확장과 유사한 분석 점진적 리해싱(Incremental Rehashing): Redis가 사용하는 방식. 기존/새 테이블을 동시에 유지하며, 연산마다 조금씩 이주(migration). 스파이크를 방지해 지연 민감 시스템에 적합 장점: 한 번에 긴 정지(stop-the-world)가 없음 단점: 이중 메모리 사용, 조회 시 두 테이블 모두 확인 필요 테이블 크기 선택: 소수(prime) 크기가 모듈러 해시의 분포를 개선하지만, 나눗셈이 필요해 속도가 느리다. 2의 거듭제곱 크기는 비트 마스킹(h & (m-1))으로 빨리 인덱싱하지만, 해시 함수 품질이 나쁘면 분포가 편향된다. 현대 구현은 2의 거듭제곱 + 좋은 해시 함수 조합을 선호한다.6. GraphRepresentation (Adjacency List / Matrix)flowchart LR G[그래프] G --> L["Adjacency List<br/>space O(V+E)"] G --> M["Adjacency Matrix<br/>space O(V^2)"] L --> L1[희소 그래프에 유리] M --> M1["edge lookup O(1)"] 비교 인접 리스트 인접 행렬 공간 O(V + E) O(V²) 간선 존재 확인 O(degree) O(1) 모든 이웃 순회 O(degree) O(V) 간선 추가 O(1) O(1) 희소 그래프 적합 ✅ 비효율 밀집 그래프 가능 적합 ✅ 인접 리스트 구현:// 가중 그래프 (인접 리스트)List<List<int[]>> graph = new ArrayList<>();for (int i = 0; i < V; i++) graph.add(new ArrayList<>());graph.get(u).add(new int[]{v, weight});// 또는 간선 리스트 (Bellman-Ford, Kruskal에 적합)int[][] edges = { {u, v, weight}, ... };실전 고려사항: CSR (Compressed Sparse Row): 대규모 정적 그래프에서 메모리/캐시 효율이 좋은 표현. 두 배열(indptr, indices)로 인접 관계를 인코딩. 수정이 어렵지만 순회가 매우 빠름 행렬 곱셈과 경로: 인접 행렬 $A$의 $k$제곱 $A^k[i][j]$는 i에서 j로 가는 길이 k인 경로의 수. Floyd-Warshall도 행렬 관점에서 이해할 수 있음BFS / DFSflowchart LR BFS[BFS] --> Q[Queue] BFS --> BL["레벨 순서 탐색"] DFS[DFS] --> ST["Stack / Recursion"] DFS --> DP[깊게 탐색]BFS (Breadth-First Search): 자료구조: 큐 최단 경로: 무가중치 그래프에서 최소 간선 수 경로를 보장 시간: O(V + E) 활용: 최단 거리, 연결 요소, 이분 그래프 판별, 레벨 순회public static int[] bfs(List<List<Integer>> graph, int start, int V) { boolean[] visited = new boolean[V]; int[] dist = new int[V]; Arrays.fill(dist, -1); Queue<Integer> queue = new LinkedList<>(); visited[start] = true; dist[start] = 0; queue.offer(start); while (!queue.isEmpty()) { int u = queue.poll(); for (int v : graph.get(u)) { if (!visited[v]) { visited[v] = true; dist[v] = dist[u] + 1; queue.offer(v); } } } return dist;}DFS (Depth-First Search): 자료구조: 스택 (또는 재귀 호출 스택) 시간: O(V + E) 활용: 사이클 검출, 위상 정렬, 강연결 요소(SCC), 관절점/다리, 백트래킹DFS 간선 분류 (방향 그래프): Tree Edge: DFS 트리에 포함되는 간선 Back Edge: 조상으로 향하는 간선 → 사이클이 존재함을 의미 Forward Edge: 자손으로 향하는 간선 (트리 간선이 아닌) Cross Edge: 형제 서브트리 간 간선이분 그래프(Bipartite) 판별: BFS/DFS로 2-색칠 시도. 인접한 노드가 같은 색이면 이분 그래프가 아님.강연결 요소(SCC): 방향 그래프에서 모든 정점 쌍이 서로 도달 가능한 최대 집합. Tarjan’s Algorithm (단일 DFS, O(V+E)) 또는 Kosaraju’s Algorithm (DFS 2회)으로 구함.Dijkstraflowchart TD A["dist(source)=0, 나머지=INF 초기화"] --> B["source를 min-heap에 push"] B --> C{Heap이 비었나?} C -->|Yes| H[완료] C -->|No| D[최소 거리 node pop] D --> E[모든 outgoing edge 완화] E --> F{거리 갱신이 일어났나?} F -->|Yes| G["dist 갱신 + heap에 push"] F -->|No| C G --> C다익스트라는 음수 간선이 없는 그래프에서 단일 시작점 최단 경로를 구한다.public static int[] dijkstra(List<List<int[]>> graph, int start, int V) { int[] dist = new int[V]; Arrays.fill(dist, Integer.MAX_VALUE); dist[start] = 0; // {거리, 노드} PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> a[0] - b[0]); pq.offer(new int[]{0, start}); while (!pq.isEmpty()) { int[] cur = pq.poll(); int d = cur[0], u = cur[1]; if (d > dist[u]) continue; // 이미 더 짧은 경로를 찾은 경우 스킵 for (int[] edge : graph.get(u)) { int v = edge[0], w = edge[1]; int nd = d + w; if (nd < dist[v]) { dist[v] = nd; pq.offer(new int[]{nd, v}); } } } return dist;}복잡도: 이진 힙: O((V + E) log V) 피보나치 힙: O(V log V + E) — decrease-key가 O(1) 밀집 그래프(E ≈ V²)에서 배열 기반: O(V²) — 힙 없이 선형 탐색이 더 빠를 수 있음음수 간선 불가 이유: Dijkstra는 확정한 노드의 거리가 최단임을 가정하고 다시 방문하지 않는 그리디 전략을 사용한다. 음수 간선이 있으면 확정 후에도 더 짧은 경로가 나타날 수 있어 정확성이 깨진다.A* Algorithm: Dijkstra에 휴리스틱 $h(v)$(목표까지의 추정 거리)를 추가. 우선순위 = $g(v) + h(v)$로 목표에 가까운 방향을 우선 탐색한다. 휴리스틱이 admissible(절대 과대평가하지 않음)이면 최적해를 보장한다. 맵 내비게이션, 게임 AI 경로탐색에 활용. Manhattan/Euclidean distance가 일반적인 휴리스틱이다.Bellman-Fordflowchart TD I["dist(source)=0 초기화"] --> R["V-1번 반복: 모든 edge relax"] R --> C[한 번 더 검사] C --> N{아직도 relax되는 edge가 있나?} N -->|Yes| NEG[음수 사이클 존재] N -->|No| DONE[최단 경로 확정]벨만-포드는 음수 간선이 있어도 최단 경로를 계산할 수 있고, 음수 사이클 탐지도 가능하다.알고리즘: 모든 거리를 ∞로 초기화, 출발점 = 0 V-1번 반복: 모든 간선 (u, v, w)에 대해 dist[v] = min(dist[v], dist[u] + w) (완화) 한 번 더 반복해 거리가 줄어드는 간선이 있으면 → 음수 사이클 존재복잡도: O(VE). Early termination 최적화: 한 라운드에서 업데이트가 없으면 즉시 종료.SPFA (Shortest Path Faster Algorithm): Bellman-Ford의 큐 기반 최적화. 완화에 성공한 노드만 큐에 넣어 불필요한 완화를 줄인다. 평균적으로 빠르지만 최악은 여전히 O(VE). 알고리즘 대회에서 자주 사용되지만, 최악 케이스가 쉽게 구성될 수 있어 실전에서는 주의가 필요하다.Floyd-Warshallflowchart TD A[dist 행렬 초기화] --> K[for k in 1..V] K --> I[for i in 1..V] I --> J[for j in 1..V] J --> U["dist(i,j) = min(dist(i,j), dist(i,k) + dist(k,j))"] U --> O["모든 정점 쌍 최단 경로"]플로이드-워셜은 모든 정점 쌍 최단 경로를 O(V³)로 계산한다.핵심 아이디어 (DP): 중간 노드를 1부터 k까지만 사용했을 때의 최단 거리를 점진적으로 확장한다:\(dp[k][i][j] = \min(dp[k-1][i][j], \; dp[k-1][i][k] + dp[k-1][k][j])\)실전에서는 2D 배열 하나로 in-place 업데이트한다:// dist[i][j] = 간선 가중치 (없으면 INF, dist[i][i] = 0)for (int k = 0; k < V; k++) { for (int i = 0; i < V; i++) { for (int j = 0; j < V; j++) { if (dist[i][k] != INF && dist[k][j] != INF) { dist[i][j] = Math.min(dist[i][j], dist[i][k] + dist[k][j]); } } }}음수 사이클 검출: 알고리즘 종료 후 dist[i][i] < 0인 노드가 있으면 음수 사이클에 포함됨.적용 조건: V ≤ ~500 정도에서 실용적. V가 크면 Dijkstra를 V번 실행하는 것이 더 효율적일 수 있다 (E가 희소할 때). 경로 복원 시 별도의 next[i][j] 행렬을 유지한다.Topological Sortflowchart TD A[indegree 계산] --> B[indegree 0 노드 push] B --> C[node를 pop하고 출력] C --> D[이웃의 indegree 감소] D --> E{new indegree 0?} E -->|Yes| B E -->|No| C위상 정렬은 DAG(Directed Acyclic Graph)에서 선행 제약을 만족하는 노드 순서를 구한다.두 가지 알고리즘: Kahn’s Algorithm (BFS 기반): 진입차수(in-degree) 0인 노드를 큐에 넣고 처리하며, 인접 노드의 진입차수를 감소. O(V + E). 사이클 감지도 가능 (처리된 노드 수 < V이면 사이클 존재) DFS 기반: DFS 후위 순서를 역순으로 나열. O(V + E)// Kahn's Algorithmpublic static List<Integer> topologicalSort(List<List<Integer>> graph, int[] inDegree, int V) { Queue<Integer> queue = new LinkedList<>(); for (int v = 0; v < V; v++) { if (inDegree[v] == 0) queue.offer(v); } List<Integer> order = new ArrayList<>(); while (!queue.isEmpty()) { int u = queue.poll(); order.add(u); for (int v : graph.get(u)) { inDegree[v]--; if (inDegree[v] == 0) queue.offer(v); } } return order.size() == V ? order : null; // null = 사이클 존재}활용: 빌드 시스템(Make, Gradle), 패키지 의존성 해결, 데이터 파이프라인 스케줄링, 선수 과목 체계, 스프레드시트 셀 계산 순서.사전순 위상 정렬: 여러 유효한 위상 정렬 중 사전순으로 가장 앞서는 것을 구하려면, 큐 대신 min-heap을 사용한다.Union-Findflowchart LR U["union(a,b)"] --> FA["find(a)"] U --> FB["find(b)"] FA --> C{rootA != rootB?} FB --> C C -->|Yes| M["rank / size 기준 병합"] C -->|No| N[이미 연결됨] P[find의 path compression] --> FA P --> FBUnion-Find(Disjoint Set Union, DSU)는 원소 집합 분리/병합을 효율적으로 관리한다.핵심 최적화: 경로 압축(Path Compression): find(x) 시 경로의 모든 노드를 직접 루트에 연결. 트리 높이를 사실상 1로 만듦 Union by Rank/Size: 작은 트리를 큰 트리 아래에 붙임. 편향 방지class UnionFind { int[] parent, rank; UnionFind(int n) { parent = new int[n]; rank = new int[n]; for (int i = 0; i < n; i++) parent[i] = i; } int find(int x) { if (parent[x] != x) parent[x] = find(parent[x]); // Path Compression return parent[x]; } boolean union(int x, int y) { int px = find(x), py = find(y); if (px == py) return false; if (rank[px] < rank[py]) { int tmp = px; px = py; py = tmp; } parent[py] = px; // Union by Rank if (rank[px] == rank[py]) rank[px]++; return true; }}복잡도: 두 최적화 모두 적용 시, m번의 union/find 연산 총 비용 O(m · α(n)). α(n)은 역 아커만 함수로, 실용적인 모든 입력에서 ≤ 4. 사실상 상수 시간.활용: Kruskal MST, 동적 연결성(connected components), 네트워크 장비 그룹핑, 최소 공통 조상(Offline LCA), 등가 클래스 분류.Weighted Union-Find: 각 원소에 루트까지의 상대적 가중치를 저장. “A와 B의 차이가 d”라는 관계형 정보를 관리할 수 있다. 조건부 관계/차이 제약 문제(ACM/ICPC 빈출)에 활용.MST (Kruskal / Prim)flowchart LR K1["Kruskal: 가중치 순으로 edge 정렬"] --> K2["사이클이 없으면 edge 추가 (Union-Find)"] P1["Prim: 시작 node에서 tree 확장"] --> P2["최소 crossing edge 선택 (PQ)"] K2 --> MST["Minimum Spanning Tree(MST)"] P2 --> MST최소 신장 트리(MST)는 모든 정점을 최소 비용으로 연결하는 V-1개의 간선 집합이다.Kruskal’s Algorithm: 모든 간선을 가중치 순 정렬: O(E log E) 작은 간선부터, 사이클을 만들지 않으면(Union-Find 확인) MST에 추가 V-1개 간선 선택되면 종료 복잡도: O(E log E + E · α(V)) ≈ O(E log E) 적합: 희소 그래프 (E ≪ V²) Prim’s Algorithm: 임의 정점에서 시작 MST에 인접한 간선 중 가중치 최소인 것을 선택 (Priority Queue) V-1개 간선 선택될 때까지 반복 복잡도: 이진 힙 O((V + E) log V), 피보나치 힙 O(E + V log V) 적합: 밀집 그래프 Cut Property (절단 성질): MST의 이론적 기반. 그래프의 임의 절단(cut)에서 가중치가 최소인 간선은 반드시 MST에 포함된다. Kruskal과 Prim 모두 이 성질을 활용한다.MST의 유일성: 모든 간선 가중치가 서로 다르면 MST는 유일하다. 같은 가중치가 있으면 여러 MST가 존재할 수 있다.Borůvka’s Algorithm: 각 컴포넌트에서 최소 외부 간선을 동시에 선택. 병렬화에 유리. O(E log V). 분산 환경에서 활용. 각 라운드에서 컴포넌트 수가 최소 절반으로 줄어 O(log V) 라운드만 필요하다.7. Advanced StructuresTriegraph TD R((root)) --> A((a)) A --> AP((p)) AP --> APP((p*)) AP --> APL((l)) APL --> APLE((e*)) R --> B((b)) B --> BA((a)) BA --> BAT((t*))Trie(Prefix Tree)는 문자열 집합을 문자 단위 경로로 저장하는 트리다. 접두사 검색(prefix query)과 자동완성에 매우 강하다.class TrieNode { Map<Character, TrieNode> children = new HashMap<>(); boolean isEnd = false; int count = 0; // 이 접두사를 가진 단어 수 (선택)}class Trie { TrieNode root = new TrieNode(); void insert(String word) { TrieNode node = root; for (char ch : word.toCharArray()) { node.children.putIfAbsent(ch, new TrieNode()); node = node.children.get(ch); node.count++; } node.isEnd = true; } boolean search(String word) { // 정확히 존재하는지 TrieNode node = find(word); return node != null && node.isEnd; } boolean startsWith(String prefix) { // 접두사로 시작하는 단어 존재? return find(prefix) != null; } private TrieNode find(String prefix) { TrieNode node = root; for (char ch : prefix.toCharArray()) { if (!node.children.containsKey(ch)) return null; node = node.children.get(ch); } return node; }}복잡도: 삽입/검색 O(L), L=문자열 길이. 해시 테이블 O(L)과 같지만, Trie는 접두사 기반 연산에서 압도적으로 유리하다.메모리 최적화: 배열 기반 자식: 알파벳 크기가 고정(26)이면 children[26] 배열. 해시 맵보다 빠르지만 메모리 낭비 가능 Compressed Trie (Patricia Tree / Radix Tree): 자식이 하나뿐인 노드를 합쳐 간선에 문자열을 저장. 노드 수를 크게 줄임. Linux 커널 라우팅 테이블에서 사용 Double-Array Trie: 두 개의 배열(base, check)로 Trie를 인코딩. 매우 메모리 효율적. 형태소 분석기(MeCab), 입력기(librime)에서 활용활용: 자동완성, IP 라우팅(Longest Prefix Match), 사전 검색, XOR 최대값 쿼리 (bitwise trie), 문자열 패턴 매칭.Suffix Arrayflowchart TD S[String S] --> SUF[모든 suffix 생성] SUF --> SORT[suffix를 사전순 정렬] SORT --> SA["시작 인덱스 저장 = Suffix Array"] SA --> LCP[빠른 질의를 위한 LCP array]Suffix Array는 문자열의 모든 접미사를 사전순 정렬한 인덱스 배열이다.예: s = "banana"접미사: banana(0), anana(1), nana(2), ana(3), na(4), a(5)정렬: a(5), ana(3), anana(1), banana(0), na(4), nana(2)SA = [5, 3, 1, 0, 4, 2]구성 알고리즘: 단순 정렬: O(n² log n). 모든 접미사를 문자열 비교 기반 정렬 Prefix Doubling: O(n log² n). 길이 1, 2, 4, … 접두사 기준으로 반복 정렬 SA-IS (Induced Sorting): O(n). 선형 시간 구성. 실전에서 가장 효율적LCP Array (Longest Common Prefix): SA에서 인접한 접미사들의 최장 공통 접두사 길이를 저장한 배열. Kasai’s Algorithm으로 O(n)에 구성 가능.SA + LCP Array 활용: 패턴 검색: 이진 탐색으로 O(m log n), m=패턴 길이 최장 반복 부분 문자열: LCP 배열의 최댓값 서로 다른 부분 문자열의 수: $n(n+1)/2 - \sum LCP[i]$ 최장 공통 부분 문자열 (두 문자열): 두 문자열을 연결하고 SA/LCP 구성 후, 서로 다른 문자열에서 온 인접 접미사의 최대 LCPSuffix Treegraph TD R((root)) --> A[a...] R --> B[b...] A --> A1[na$] A --> A2[pple$] B --> B1[anana$] B --> B2[and$]Suffix Tree는 모든 접미사를 압축 트리 형태로 표현해 다양한 문자열 질의를 선형 시간에 지원한다.Ukkonen’s Algorithm: O(n) 온라인 구성 알고리즘. 핵심 트릭: 암묵적 확장(implicit extension): 리프 끝을 전역 포인터로 관리해 O(1) 확장 Suffix link: 활성 지점(active point)을 빠르게 이동 Rule 3 (이미 존재하는 확장 중단): 더 이상의 확장이 불필요Suffix Tree 응용: O(m) 패턴 매칭 (m=패턴 길이) 최장 반복 부분 문자열: 가장 깊은 분기 노드 최장 회문, 최장 공통 부분 문자열 문자열 압축 (Lempel-Ziv)실전에서는 Suffix Array + LCP Array 조합이 메모리 면에서 더 효율적이어서, Suffix Tree를 직접 구성하기보다 SA를 사용하는 경우가 많다.LRU Cache (Hash + Doubly Linked List)flowchart LR M["HashMap key->node"] --> N1[노드 A] M --> N2[노드 B] M --> N3[노드 C] H["Head MRU(가장 최근)"] <--> N1 N1 <--> N2 N2 <--> N3 N3 <--> T["Tail LRU(가장 오래됨)"]LRU(Least Recently Used) 캐시는 “가장 오래 사용되지 않은” 항목을 우선 제거한다.자료구조: HashMap<key, Node> + Doubly Linked List get(key): HashMap으로 O(1) 조회 → 해당 노드를 리스트 앞(most recent)으로 이동 put(key, value): 이미 존재하면 업데이트 + 앞으로 이동. 새 키면 앞에 삽입. 용량 초과 시 리스트 꼬리(least recent) 삭제 + HashMap에서도 삭제 모든 연산 O(1)class LRUCache extends LinkedHashMap<Integer, Integer> { private final int capacity; public LRUCache(int capacity) { super(capacity, 0.75f, true); // accessOrder = true this.capacity = capacity; } public int get(int key) { return super.getOrDefault(key, -1); } public void put(int key, int value) { super.put(key, value); } @Override protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) { return size() > capacity; // 용량 초과 시 가장 오래된 항목 자동 제거 }}캐시 교체 정책 비교: LRU: 가장 최근에 사용되지 않은 항목 제거. 시간 지역성 활용. 단, 한 번만 접근된 대량 데이터가 캐시를 오염시킬 수 있음 LFU (Least Frequently Used): 가장 적게 사용된 항목 제거. 빈도 기반. 새 항목이 불리(cold start) ARC (Adaptive Replacement Cache): LRU + LFU를 동적으로 조합. 워크로드에 적응. ZFS에서 사용 CLOCK: LRU의 근사. 원형 버퍼 + 참조 비트로 구현. OS 페이지 교체에서 사용 (정확한 LRU보다 오버헤드 작음)O(1) LFU: HashMap + 빈도별 Doubly Linked List의 HashMap 구조로 삽입/삭제/조회 모두 O(1)에 구현 가능.Skip Listflowchart TD L3["Level 3: 듬성한 express lane"] --> L2[Level 2] L2 --> L1[Level 1] L1 --> L0["Level 0: 전체 정렬 리스트"] L3 --> S[다음 key가 너무 크면 아래 레벨로 내려감]스킵 리스트는 다층 연결 리스트로 평균 O(log n) 탐색/삽입/삭제를 제공한다.구조: 최하위 레벨은 모든 원소를 정렬 순서로 포함하는 연결 리스트. 상위 레벨은 하위 레벨의 일부 원소를 포함하며, 각 원소가 레벨 i에 나타날 확률은 p^i (보통 p = 0.5 또는 0.25).탐색 과정: 최상위 레벨에서 시작해 오른쪽→아래로 이동하며 목표를 찾는다. “고속도로 → 국도 → 골목길”과 유사하다.기대 높이: $O(\log_{1/p} n)$. p=0.5이면 ~$\log_2 n$.Level 3: 1 ────────────────────── 9Level 2: 1 ──── 4 ────────────── 9Level 1: 1 ── 3 ── 4 ── 6 ── 7 ── 9Level 0: 1 2 3 4 5 6 7 8 9균형 트리 대비 장점: 구현 단순: 회전 연산 불필요. 확률적 균형 유지 동시성 친화: 락 없이 또는 세밀한 락으로 concurrent 구현이 용이. ConcurrentSkipListMap(Java) 범위 질의 효율: 최하위 레벨이 연결 리스트이므로 범위 스캔이 자연스러움실전 사용: Redis Sorted Set: Skip List + Hash Table 조합. 점수 기반 정렬 + O(1) 조회를 동시 지원 LevelDB/RocksDB MemTable: Concurrent Skip List로 in-memory 쓰기 버퍼 구현 Java ConcurrentSkipListMap: 락-프리 동시성 정렬 맵확률적 구조의 장점: 최악 O(n)이 가능하지만, 충분히 많은 연산에서 확률적으로 O(log n)이 보장된다. 결정론적 균형 트리보다 구현과 동시성 측면에서 실용적인 장점이 있다.8. Dynamic ProgrammingDP 기본 원리flowchart TD P[문제] --> S[상태 정의] S --> R[점화식] R --> B["base case(기저 상태)"] B --> O["계산 순서 결정 (top-down/bottom-up)"] O --> A[DP 테이블에서 정답 추출]동적 프로그래밍(DP)은 문제를 최적 부분 구조(Optimal Substructure) 와 겹치는 부분 문제(Overlapping Subproblems) 를 가질 때, 부분 문제의 답을 저장(memoization/tabulation)해 중복 계산을 제거하는 기법이다.두 가지 접근법: Top-Down (Memoization): 재귀 + 캐시. 필요한 부분 문제만 계산 (lazy). 호출 스택 사용 Bottom-Up (Tabulation): 반복문으로 작은 문제부터 순서대로 채움. 스택 오버플로우 없음. 공간 최적화(rolling array) 용이DP 설계 5단계: 상태(State) 정의: dp[i], dp[i][j] 등 무엇을 의미하는지 명확히 정의 점화식(Recurrence Relation): 큰 문제를 작은 문제로 표현 초기값(Base Case): 재귀의 종료 조건 / 테이블의 시작 값 계산 순서: 의존성에 따라 채우는 방향 결정 최종 답 위치: dp[n], dp[0][n-1] 등배낭 문제 (Knapsack)flowchart LR S["state dp(i,w)"] --> A1["item i를 건너뜀<br/>dp(i-1,w)"] S --> A2["item i를 선택<br/>dp(i-1,w-wi) + vi"] A1 --> M[max] A2 --> M M --> N["dp(i,w)"]DP의 대표 문제 군이며, 변형이 다양하게 출제된다.0/1 Knapsack각 아이템을 선택(1)하거나 안 하거나(0). 중복 선택 불가.상태: dp[i][w] = 처음 i개 아이템으로 무게 w 이하에서 달성 가능한 최대 가치점화식: $dp[i][w] = \max(dp[i-1][w], \; dp[i-1][w - w_i] + v_i)$// 0/1 Knapsack (1D 공간 최적화)public static int knapsack01(int[] weights, int[] values, int W) { int n = weights.length; int[] dp = new int[W + 1]; for (int i = 0; i < n; i++) { for (int w = W; w >= weights[i]; w--) { // 역순 순회 (중복 방지) dp[w] = Math.max(dp[w], dp[w - weights[i]] + values[i]); } } return dp[W];}핵심 포인트: 1D 최적화 시 역순 순회가 필수. 정순이면 같은 아이템을 여러 번 사용하게 된다.Unbounded Knapsack (완전 배낭)각 아이템을 무한히 선택 가능.점화식: $dp[w] = \max_{i}(dp[w], \; dp[w - w_i] + v_i)$// Unbounded Knapsackpublic static int knapsackUnbounded(int[] weights, int[] values, int W) { int[] dp = new int[W + 1]; for (int w = 1; w <= W; w++) { for (int i = 0; i < weights.length; i++) { if (weights[i] <= w) { dp[w] = Math.max(dp[w], dp[w - weights[i]] + values[i]); } } } return dp[W];}배낭 문제 변형 패턴 변형 핵심 차이 순회 순서 0/1 Knapsack 각 아이템 1번 무게 역순 Unbounded Knapsack 각 아이템 무제한 무게 정순 Bounded Knapsack 각 아이템 k개 제한 이진 분해 → 0/1로 환원 Subset Sum 가치 = 무게, 목표 합 존재 여부 boolean dp Coin Change (최소 개수) 동전 무제한, 최소 동전 수 dp[w] = min(dp[w], dp[w-coin]+1) Coin Change (경우의 수) 조합 수 동전 외부 루프 = 조합, 무게 외부 루프 = 순열 LIS (Longest Increasing Subsequence)flowchart LR N[입력 수열] --> T[tails 배열] T --> BS[삽입 위치 이진 탐색] BS --> REP[치환 또는 뒤에 추가] REP --> LEN["tails 길이 = LIS 길이"]최장 증가 부분 수열은 면접에서 매우 빈출되는 DP 주제다.O(n²) DPdp[i] = nums[i]를 마지막 원소로 하는 LIS 길이public static int lisDP(int[] nums) { int n = nums.length; int[] dp = new int[n]; Arrays.fill(dp, 1); int maxLen = 1; for (int i = 1; i < n; i++) { for (int j = 0; j < i; j++) { if (nums[j] < nums[i]) { dp[i] = Math.max(dp[i], dp[j] + 1); } } maxLen = Math.max(maxLen, dp[i]); } return maxLen;}O(n log n) 이진 탐색tails[i] = 길이 (i+1)인 증가 부분 수열의 마지막 원소 중 최솟값. tails는 항상 정렬 상태를 유지한다.public static int lisBinarySearch(int[] nums) { List<Integer> tails = new ArrayList<>(); for (int num : nums) { int pos = Collections.binarySearch(tails, num); if (pos < 0) pos = -(pos + 1); if (pos == tails.size()) { tails.add(num); } else { tails.set(pos, num); } } return tails.size();}직관: 각 숫자가 들어올 때, tails에서 해당 값이 들어갈 위치를 이진 탐색으로 찾아 교체. tails의 길이가 LIS 길이. (단, tails 자체가 실제 LIS는 아님)LIS 변형: 최장 비감소 부분 수열: <를 <=로 변경 (이진 탐색에서 upper_bound 사용) 실제 수열 복원: 별도의 parent 배열로 역추적 가장 긴 증가 부분 수열의 개수: DP + Fenwick Tree로 O(n log n)Interval DPflowchart TD I["구간 (l,r)"] --> K[분할점 k 시도] K --> L["dp(l,k)"] K --> R["dp(k+1,r)"] L --> M["cost(l,k,r)와 결합"] R --> M M --> ANS["dp(l,r)의 최적값"]구간 DP는 구간 [i, j]를 상태로 두고, 구간을 분할하는 모든 경계점 k를 시도하는 패턴이다.일반 형태: $dp[i][j] = \min_{i \leq k < j}(dp[i][k] + dp[k+1][j] + cost(i, j))$순회 순서: 구간 길이가 짧은 것부터 긴 것 순서로 채움.행렬 곱셈 순서 (Matrix Chain Multiplication)N개의 행렬을 곱할 때, 곱셈 순서에 따라 스칼라 곱셈 횟수가 달라진다. 최적 순서를 찾는 문제.// Matrix Chain Multiplication// dims[i] = i번째 행렬의 행 수, dims[n] = 마지막 행렬의 열 수// 행렬 i는 dims[i] × dims[i+1]public static int matrixChain(int[] dims) { int n = dims.length - 1; // 행렬 개수 int[][] dp = new int[n][n]; // len: 구간 길이 for (int len = 2; len <= n; len++) { for (int i = 0; i <= n - len; i++) { int j = i + len - 1; dp[i][j] = Integer.MAX_VALUE; for (int k = i; k < j; k++) { int cost = dp[i][k] + dp[k + 1][j] + dims[i] * dims[k + 1] * dims[j + 1]; dp[i][j] = Math.min(dp[i][j], cost); } } } return dp[0][n - 1];}대표적인 Interval DP 문제들 문제 상태 점화식 핵심 행렬 곱셈 순서 dp[i][j] = 최소 곱셈 수 분할점 k로 두 부분의 합 + 결합 비용 최장 팰린드롬 부분 수열 dp[i][j] = 길이 s[i]==s[j]이면 dp[i+1][j-1]+2 풍선 터뜨리기 (Burst Balloons) dp[i][j] = 최대 코인 마지막에 터뜨릴 풍선 k를 선택 최적 BST dp[i][j] = 최소 탐색 비용 루트 k 선택, 좌우 서브트리 + 깊이 비용 괄호 칠하기 dp[i][j] = 최소 비용 매칭 구조를 분석해 분할 기타 DP 패턴flowchart LR DP[Dynamic Programming 패턴] --> BIT[Bitmask DP] DP --> TREE[Tree DP] DP --> DIGIT[Digit DP] DP --> KNUTH[최적화 기법]비트마스크 DP집합을 비트마스크로 표현하는 DP. 원소 수가 작을 때(보통 ≤ 20) 사용한다.대표: 외판원 문제 (TSP) 상태: dp[mask][i] = 방문 집합이 mask이고 현재 위치가 i일 때의 최소 비용 복잡도: $O(2^n \cdot n^2)$ — 완전 탐색 $O(n!)$보다 훨씬 효율적 mask & (1 << j): j번 도시 방문 여부 확인 mask | (1 << j): j번 도시를 방문 집합에 추가트리 DP트리 구조에서 DFS를 수행하며 자식→부모 방향으로 DP 값을 합산한다. 트리의 지름: 각 노드에서 아래로 가는 최장 경로 2개의 합. DFS로 O(n) 트리에서의 독립 집합: dp[v][0/1] (선택 안 함/함). 선택하면 자식은 선택 불가자릿수 DP (Digit DP)0~N 범위에서 특정 조건을 만족하는 수의 개수를 구할 때 사용한다. 상태: dp[pos][tight][상태] — pos는 현재 자릿수, tight는 상한 제약 여부 활용: “1~N에서 특정 숫자가 포함된 수의 개수”, “자릿수 합이 k인 수의 개수”9. String AlgorithmsKMP (Knuth-Morris-Pratt)flowchart TD A["text(i)와 pattern(j) 비교"] --> B{일치하나?} B -->|Yes| C["i++, j++"] C --> D{j == m?} D -->|Yes| E["매치 기록<br/>j = lps(j-1)"] D -->|No| A B -->|No and j > 0| F["j = lps(j-1)"] F --> A B -->|No and j == 0| G["i++"] G --> AKMP는 텍스트 T에서 패턴 P를 O(n + m)에 검색하는 알고리즘이다. 불일치(mismatch) 시 불필요한 비교를 건너뛰는 실패 함수(Failure Function, LPS 배열) 가 핵심이다.LPS (Longest Proper Prefix which is also Suffix) 배열lps[i] = 패턴 P[0..i]의 접두사이면서 접미사인 최장 길이 (자기 자신 제외)예: P = “ABACABAB”인덱스: 0 1 2 3 4 5 6 7패턴 : A B A C A B A BLPS : 0 0 1 0 1 2 3 2lps[6] = 3: P[0..6] = “ABACABA”에서 접두사 “ABA”와 접미사 “ABA”가 일치 (길이 3)KMP 구현// LPS 배열 구성public static int[] buildLPS(String pattern) { int m = pattern.length(); int[] lps = new int[m]; int len = 0; // 이전까지의 최장 접두사=접미사 길이 int i = 1; while (i < m) { if (pattern.charAt(i) == pattern.charAt(len)) { lps[i++] = ++len; } else if (len > 0) { len = lps[len - 1]; // 핵심: 처음으로 돌아가지 않고 lps를 활용 } else { lps[i++] = 0; } } return lps;}// KMP 검색public static List<Integer> kmpSearch(String text, String pattern) { int[] lps = buildLPS(pattern); List<Integer> result = new ArrayList<>(); int i = 0, j = 0; // i: 텍스트 인덱스, j: 패턴 인덱스 while (i < text.length()) { if (text.charAt(i) == pattern.charAt(j)) { i++; j++; } if (j == pattern.length()) { result.add(i - j); // 매칭 위치 j = lps[j - 1]; } else if (i < text.length() && text.charAt(i) != pattern.charAt(j)) { if (j > 0) { j = lps[j - 1]; // 패턴 내에서 점프 } else { i++; } } } return result;}KMP의 핵심 직관: 불일치 시 패턴의 이미 일치한 부분에서 “접두사=접미사”를 이용해 비교 위치를 최대한 건너뛴다. 텍스트의 인덱스 i는 절대 뒤로 가지 않으므로 O(n + m)이 보장된다.나이브 알고리즘과 비교 알고리즘 시간 복잡도 공간 복잡도 패턴 전처리 Naive O(nm) O(1) 없음 KMP O(n + m) O(m) LPS 배열 Rabin-Karp O(n + m) 평균, O(nm) 최악 O(1) 해시 계산 Boyer-Moore O(n/m) 최선, O(nm) 최악 O(m + σ) Bad Character + Good Suffix Boyer-Moore: 패턴을 뒤에서 앞으로 비교하며, Bad Character Rule과 Good Suffix Rule로 대폭 건너뛴다. 실전에서 가장 빠른 단일 패턴 매칭 (텍스트 에디터의 Ctrl+F 등). 알파벳이 클수록 효율적.Aho-Corasickflowchart TD T1[패턴으로 trie 구성] --> T2[BFS로 failure link 구성] T2 --> T3[text를 한 글자씩 스캔] T3 --> T4["goto/fail 전이 따라가기"] T4 --> T5[매치된 패턴 출력]Aho-Corasick은 여러 패턴을 동시에 텍스트에서 검색하는 알고리즘이다. Trie + KMP의 실패 함수를 결합한 오토마톤(automaton)을 구성한다.구성 3단계 Trie 구축: 모든 패턴을 Trie에 삽입 Failure Link (실패 링크): BFS로 구성. 각 노드의 실패 링크는 현재 노드의 접미사 중 Trie에 존재하는 최장 접두사 노드를 가리킴 (KMP의 LPS와 동일 개념) Output Link (사전 링크): 실패 링크를 따라가며 추가로 매칭되는 패턴을 연결class AhoCorasick { int[][] go; // go[node][char] = 다음 노드 int[] fail; // 실패 링크 List<List<Integer>> output; // 각 노드에서 매칭되는 패턴 인덱스 int size; AhoCorasick(int maxNodes, int alphabet) { go = new int[maxNodes][alphabet]; fail = new int[maxNodes]; output = new ArrayList<>(); for (int i = 0; i < maxNodes; i++) { Arrays.fill(go[i], -1); output.add(new ArrayList<>()); } size = 1; // root = 0 } void insert(String pattern, int idx) { int cur = 0; for (char ch : pattern.toCharArray()) { int c = ch - 'a'; if (go[cur][c] == -1) go[cur][c] = size++; cur = go[cur][c]; } output.get(cur).add(idx); } void build() { Queue<Integer> queue = new LinkedList<>(); // 루트의 직접 자식 초기화 for (int c = 0; c < go[0].length; c++) { if (go[0][c] == -1) { go[0][c] = 0; // 루트로 되돌림 } else { fail[go[0][c]] = 0; queue.offer(go[0][c]); } } // BFS로 실패 링크 구성 while (!queue.isEmpty()) { int u = queue.poll(); for (int c = 0; c < go[0].length; c++) { if (go[u][c] == -1) { go[u][c] = go[fail[u]][c]; // goto 함수 완성 } else { fail[go[u][c]] = go[fail[u]][c]; output.get(go[u][c]).addAll(output.get(fail[go[u][c]])); // output link queue.offer(go[u][c]); } } } } // 텍스트에서 모든 패턴 검색 List<int[]> search(String text) { // [위치, 패턴 인덱스] List<int[]> results = new ArrayList<>(); int cur = 0; for (int i = 0; i < text.length(); i++) { cur = go[cur][text.charAt(i) - 'a']; for (int idx : output.get(cur)) { results.add(new int[]{i, idx}); } } return results; }}복잡도: Trie 구축: O(Σ|P_i|) (패턴 길이 총합) 실패 링크 구성: O(Σ|P_i| × σ) (σ = 알파벳 크기) 검색: O(n + 매칭 수), n = 텍스트 길이활용 사례 사용처 설명 네트워크 침입 탐지 (IDS/IPS) Snort 등에서 패킷 페이로드에 수천 개 시그니처를 동시 매칭 바이러스 스캐너 ClamAV 등에서 악성코드 패턴 DB를 한 번에 검색 텍스트 필터링 금칙어/욕설 필터. 다수의 금칙어를 O(n)에 모두 탐지 DNA 서열 분석 여러 유전자 패턴을 게놈에서 동시 검색 검색 엔진 키워드 하이라이팅, 다중 키워드 검색 문자열 해싱 (Rabin-Karp)flowchart TD A[pattern hash 계산] --> B[첫 window hash 계산] B --> C{hash가 일치하나?} C -->|Yes| D[부분 문자열 최종 확인] C -->|No| E[다음 window로 rolling hash] D --> E E --> F[끝까지 반복]Rabin-Karp는 롤링 해시를 사용해 패턴 매칭을 수행한다. 단일 패턴에서는 KMP보다 느릴 수 있지만, 다중 패턴, 부분 문자열 중복 검사 등에서 강력하다.롤링 해시: 윈도우를 한 칸 이동할 때 O(1)에 해시를 갱신:\(H(s[i+1..i+m]) = (H(s[i..i+m-1]) - s[i] \cdot p^{m-1}) \cdot p + s[i+m]\)// Rabin-Karp 패턴 매칭public static List<Integer> rabinKarp(String text, String pattern) { int n = text.length(), m = pattern.length(); long MOD = 1_000_000_007, BASE = 31; List<Integer> result = new ArrayList<>(); // 패턴 해시 & base^(m-1) 계산 long patHash = 0, txtHash = 0, power = 1; for (int i = 0; i < m; i++) { patHash = (patHash * BASE + pattern.charAt(i)) % MOD; txtHash = (txtHash * BASE + text.charAt(i)) % MOD; if (i > 0) power = power * BASE % MOD; } for (int i = 0; i <= n - m; i++) { if (patHash == txtHash && text.substring(i, i + m).equals(pattern)) { result.add(i); // 해시 일치 + 실제 문자열 비교 (spurious hit 방지) } if (i < n - m) { txtHash = ((txtHash - text.charAt(i) * power % MOD + MOD) * BASE + text.charAt(i + m)) % MOD; } } return result;}이중 해시(Double Hashing): 서로 다른 MOD/BASE로 두 개의 해시를 동시에 사용하면 충돌 확률이 $1/MOD^2$로 급감한다. 해시 기반 비교가 안전해져 실제 문자열 비교를 생략할 수 있다.활용: 최장 공통 부분 문자열: 이진 탐색 + 롤링 해시로 O(n log n) 최장 반복 부분 문자열: 이진 탐색 + 해시 집합 Plagiarism Detection: 문서의 k-gram 해시 비교 (Moss 알고리즘)
조건에 맞는 게시물이 없습니다.