diff --git "a/studies/week-01/Suyoung-Min/1767-\355\224\204\353\241\234\354\204\270\354\204\234\354\227\260\352\262\260\355\225\230\352\270\260.md" "b/studies/week-01/Suyoung-Min/1767-\355\224\204\353\241\234\354\204\270\354\204\234\354\227\260\352\262\260\355\225\230\352\270\260.md" new file mode 100644 index 0000000..fa64fcb --- /dev/null +++ "b/studies/week-01/Suyoung-Min/1767-\355\224\204\353\241\234\354\204\270\354\204\234\354\227\260\352\262\260\355\225\230\352\270\260.md" @@ -0,0 +1,240 @@ +# [SWEA 1767] 프로세서 연결하기 + +| 항목 | 내용 | +|:---|:---| +| 🔗 문제 | https://swexpertacademy.com/main/code/problem/problemDetail.do?contestProbId=AV4suNtaXFEDFAUf | +| 📚 주제 | 완전탐색 · 백트래킹 · 가지치기 | +| 🎚️ 난이도 | 모의 SW 역량테스트 샘플문제 | +| ⏱️ 소요 시간 | 1시간 반 | +| 🔁 다시 풀기 | 예 | + +--- + +## 1. 접근 방법 + +`N × N` 격자(7 ≤ N ≤ 12)에서 각 코어를 **상/하/좌/우 중 한 방향으로 직선 전선을 뻗어** 격자 밖(전원)까지 연결한다. +목표는 **연결한 코어 수를 최대화**하고, 그중에서 **전선 길이 합을 최소화**하는 것. + +먼저 두 가지를 정리하고 시작했다. + +- **가장자리 코어는 이미 전원에 연결**되어 있으므로 탐색 대상에서 제외한다 → 내부 `1 ≤ y, x ≤ N-2`만 `cores`에 담는다. +- 내부 칸은 최대 `10 × 10`이지만 문제 조건상 **코어는 최대 12개**다. + +그리디나 DP를 잠깐 고민했지만 곧 접었다. **전선끼리 서로를 가로막기 때문에 각 코어의 선택이 독립적이지 않다.** +A 코어를 위로 뻗으면 B 코어의 오른쪽 경로가 막히는 식이라, 부분 최적해를 합쳐도 전체 최적해가 안 된다. + +결국 **코어 하나당 선택지 5가지(상·하·좌·우·연결 안 함)** 를 모두 시도하는 백트래킹으로 갔다. +탐색 트리는 최대 `5^12 ≈ 2.4억`이라 그대로는 파이썬에서 감당이 안 되므로, **가지치기를 반드시 붙여야 한다.** + +**막혔던 지점 — 답이 계속 0으로 나왔다.** + +```python +best_core = max(best_core, cnum) # ❌ +best_len = min(best_len, line_length) +``` + +두 값을 **독립적으로** 갱신한 게 원인이었다. 탐색 트리에는 "모든 코어를 패스하는 경로"가 반드시 포함되고, +그 리프에서 `cnum=0, line_length=0`이 들어오면서 `best_len`이 즉시 0으로 고정되어 버렸다. +이 문제는 `(코어 수 최대, 길이 최소)`라는 **사전식(lexicographic) 비교**이므로 두 값을 묶어서 갱신해야 한다. + +## 2. 시간 복잡도 + +**O(5^C · N)** — C = 내부 코어 수(≤ 12), N = 격자 한 변(≤ 12). + +- 탐색 트리 노드 수: 코어당 5가지 선택 → 최대 `5^C` +- 노드 1개당 비용: 4방향 × 광선 순회 `O(N)` → `O(N)` +- 상한은 `5^12 × 12 ≈ 29억`이지만, **아래 두 가지 가지치기가 실질 탐색량을 줄인다.** + + +## 3. 공간 복잡도 + +**O(N²)** — 격자 `N²`가 지배적. 코어 좌표 리스트 `O(C)`, 재귀 깊이 `O(C)`. + +별도의 `visited` 배열을 만들지 않고 **격자 자체에 전선을 `2`로 기록했다가 복원**하므로 추가 메모리가 없다. + +## 4. 핵심 풀이 방법 ⭐ + +**① 사전식 비교 — 이 문제 최대의 함정** + +`max`/`min`을 따로 쓰면 안 된다. 코어 수가 갱신되는 순간 **기존 `best_len`은 다른 코어 수에서 나온 값**이므로 버려야 한다. + +```python +if cnum > best_core: + best_core, best_len = cnum, line_length # 개수 갱신 → 길이는 리셋(대입) +elif cnum == best_core: + best_len = min(best_len, line_length) # 동점일 때만 min +``` + +`cnum > best_core`인데 `min`을 쓰면, 코어를 적게 연결한 경로의 짧은 길이가 그대로 살아남아 오답이 된다. +코어를 덜 연결할수록 전선도 짧으니 이 상황은 예외가 아니라 **기본값**에 가깝다. + +**② 가지치기 2종** + +```python +rest = len(cores) - cidx # 아직 결정 안 된 코어 수 + +if cnum + rest < best_core: # 개수 컷 + return +if cnum + rest == best_core and line_length >= best_len: # 길이 컷 + return +``` + +- **개수 컷**: 남은 코어를 전부 연결해도 최고 기록에 못 미치면 볼 필요 없음. +- **길이 컷**: 전선 길이는 **단조 증가만** 하므로, 동점이 최선인데 이미 길이가 밀렸으면 볼 필요 없음. + 이 컷 하나로 최악 케이스가 약 6배 빨라졌다(0.09초 → 0.015초). + +**③ `rest`에 현재 `cidx`를 포함시켜야 한다** + +`rest = len(cores) - cidx`이지 `len(cores) - 1 - cidx`가 아니다. +`backtrack(cidx, cnum, ...)` 시점에서 `cnum`은 **인덱스 0 ~ cidx-1**만 센 값이고, `cidx`는 아직 미결정 = **앞으로 연결 가능한 후보**다. + +``` +인덱스: 0 1 2 | 3 4 5 6 (len=7, cidx=3) + └─결정 완료─┘ └───미결정───┘ + cnum이 셈 rest = 7-3 = 4 +``` + +`rest`를 1 작게 잡으면 조건이 `cnum + rest ≤ best_core`가 되어 **최고 기록과 동점을 만들 수 있는 경로까지 잘라낸다.** +코어 개수는 여전히 맞고 **길이만 틀리기 때문에** 눈에 잘 안 띄는 버그다. (브루트포스 대조 시 300개 중 71개 불일치) + +**④ 전선 마킹은 격자에 직접, 복원은 무조건 안전** + +전선을 `2`로 기록하면 이후 충돌 검사가 `grid[ny][nx] != 0` **한 줄로 통일**된다. 코어(`1`)든 다른 전선(`2`)이든 똑같이 장애물이기 때문. + +복원할 때 "내 전선이 맞는지" 확인할 필요가 없다는 점이 중요하다. +**충돌 없이 격자 밖까지 뻗은 경로만 마킹**했으므로 그 광선 위의 칸은 전부 내 것이고, +하위 재귀는 자기가 칠한 걸 반드시 되돌리고 돌아오기 때문에 그냥 `0`으로 밀면 된다. + +## 5. 실제 코드 + +```python +T = int(input()) +# 여러개의 테스트 케이스가 주어지므로, 각각을 처리합니다. +for test_case in range(1, T + 1): + + n = int(input()) + + grid = [ list((map(int, input().split()))) for _ in range(n)] + + cores = [] # 코어 좌표 정보 + + best_core, best_len = 0, int(1e10) # 코어 개수 많을수록, 전선 길이 짧을수록 + + d = [(-1, 0), (1, 0), (0, -1), (0 , 1)] # 상 하 좌 우 방향 + + # 전원 off 인 코어 좌표 저장 (가장자리는 이미 연결되어 있으므로 제외) + for y in range(1, n-1): + for x in range(1, n-1): + if grid[y][x]: cores.append((y, x)) + + + def backtrack(cidx, cnum, line_length): # 현재 보고 있는 코어, 연결된 코어 개수, 전선 길이 + global best_core, best_len + + if cidx >= len(cores): # 코어 다 탐색했으면 + if cnum > best_core: + best_core, best_len = cnum, line_length # 개수 갱신 -> 길이 리셋 + elif cnum == best_core: + best_len = min(best_len, line_length) # 동점일 때만 길이 비교 + return + + # 가지치기 1 - 남은 코어 기준 + # 남은 걸 다 연결해도 최고 기록에 못 미치면 컷 + + rest = len(cores) - cidx # cidx 는 아직 미결정이므로 포함 + + if cnum + rest < best_core: return + + # 가지치기 2 - 현재 전선 기준 + # 개수 동점이 최선인데 길이가 이미 밀렸으면 컷 (길이는 늘어나기만 함) + + if cnum + rest == best_core and line_length >= best_len: return + + + # 현재 코어 탐색 하는 루트 and 안하는 루트 + + # 1. 상하좌우 전선 뻗기 + + cy, cx = cores[cidx] # 현재 코어 위치 + + for dy, dx in d: + # 해당 방향으로 전선 뻗기 + # 전선 경로 겹침 확인 + + cover_flag = False + cur_line_length = 0 + + ny, nx = cy, cx + + while True: + ny += dy + nx += dx + + # 맵 밖이면 -> 전선 연결 성공 -> 반복문 탈출 + if ny < 0 or ny >= n or nx < 0 or nx >= n: break + + if grid[ny][nx] != 0: # 빈공간이 아니면 (코어 또는 다른 전선) + cover_flag = True # 겹침 + break + + # 빈공간이면 + cur_line_length += 1 # 전선 길이 + 1 + + if not cover_flag: # 안겹치면 + # 전선 방문처리 grid[y][x] = 2 + + ny, nx = cy, cx + + while True: + ny += dy + nx += dx + + if ny < 0 or ny >= n or nx < 0 or nx >= n: break + + grid[ny][nx] = 2 # 전선 방문 처리 2 + + backtrack(cidx + 1, cnum + 1, line_length + cur_line_length) + + # 백트래킹 진행 후 전선 경로 0으로 초기화 + + ny, nx = cy, cx + + while True: + ny += dy + nx += dx + + if ny < 0 or ny >= n or nx < 0 or nx >= n: break + + grid[ny][nx] = 0 # 방문 경로 2 을 0 으로 다시 초기화 + + # 2. 현재 코어 패스 + + backtrack(cidx+1, cnum, line_length) + + + backtrack(0, 0, 0) + + print('#'+str(test_case), best_len) +``` + + +--- + +### 🧠 회고 + +- **"최대화 후 최소화" 유형은 두 값을 튜플처럼 묶어서 비교한다.** `max`/`min`을 따로 쓰면 서로 다른 조건에서 나온 값이 섞인다. 다음에 같은 유형(최대 점수 중 최소 이동 등)을 만나면 리프 갱신 로직부터 먼저 확정하고 시작하자. +- **가지치기는 "이 경로가 앞으로 최선을 넘어설 수 있는가"를 상한으로 판단한다.** 개수는 `현재 + 남은 개수`가 상한이고, 길이는 단조 증가라 `현재 값`이 하한이다. 상한/하한 방향만 정확히 잡으면 기계적으로 만들 수 있다. +- **아직 결정 안 한 원소를 `rest`에 포함시키는지 확인하는 습관.** 마지막 인덱스(`cidx == len-1`)를 대입해서 `rest == 1`이 나오는지 보면 1초 만에 검증된다. +- **개선 여지 (리뷰 받고 싶은 부분)**: 지금 코드는 방향마다 while 문이 3개(검사 / 마킹 / 복원) 반복된다. 첫 순회에서 지나간 좌표를 리스트로 모아두면 나머지 두 번은 그 리스트만 훑으면 되어 while 3개 → 함수 1개 + `for` 2줄로 줄어든다. `cover_flag`와 `cur_line_length`도 각각 `None` 반환과 `len(cells)`로 대체 가능. + + ```python + def stretch(cy, cx, dy, dx): + """맵 밖까지 뻗으면 지나간 칸 목록, 막히면 None""" + cells = [] + y, x = cy + dy, cx + dx + while 0 <= y < n and 0 <= x < n: + if grid[y][x]: return None + cells.append((y, x)) + y += dy; x += dx + return cells + ```