27차(5/13)에서 구름EDU 기출 4회차를 73분에 풀어 8/10 통과했고, 통과한 답에서도 식 버그가 2건 발견된 "테스트 통과 but 식 버그" 패턴을 확인했다. 재귀 멘탈 모델·시계 각도 공식·자릿수 분해 패턴·itertools.permutations·max(default=-1) 다섯 가지를 신규 학습했다. 함수 작성 6세션 연속 자력 정답 추세를 유지한 상태였다.
구름EDU 기출 6회차를 90분 풀타임 모의고사로 진행한 뒤, 자력으로 통과한 문제까지 포함해 7문제를 검산 모드로 복기했다. 어제 발견한 "테스트 통과 but 식 버그" 패턴을 한 번 더 의식하면서 본인이 통과처럼 보이는 답에 자가 의심을 끼워 넣는 흐름으로 진행했다.
COS Pro 1급 Python / Phase 3: 실전 대비
| 항목 | 결과 |
|---|---|
| 소요 시간 | 약 77분 (90분 중 13분 여유) |
| 자력 풀이 | 9/10 (1번 꽃피는 봄에서 막힘) |
| 추정 점수 | 800점+ 안정 (합격선 600 한참 위) |
자력 통과 5문제(4·5·8·9·10), 자가 의심 후 자력 수정 3문제(2·3·7), 힌트 후 자력 해결 2문제(1·6)로 정리됐다. 막힌 1번에 시간을 더 끌지 않고 8·9·10번을 자력으로 마무리한 시험장 판단력은 4회차와 일관됐다.
n×n 격자에서 핀 꽃(1)이 매일 상하좌우 4방향으로 한 칸씩 퍼진다. 모든 칸이 필 때까지 며칠이 걸리는지 구한다.
처음 작성한 코드:
def solution(n, garden):
answer = 0
bloom = garden[::] # 얕은 복사
dr = [-1, 0, 1, 0]
dc = [0, 1, 0, -1]
while any(any(v == 0 for v in row) for row in bloom):
for i in range(n):
for j in range(n):
if bloom[i][j]:
for d in range(4):
ni, nj = i + dr[d], j + dc[d]
if 0 <= ni < n and 0 <= nj < n:
bloom[ni][nj] = 1 # 같은 bloom을 읽으면서 수정
answer += 1
return answer
핵심 버그: bloom을 읽으면서 같은 bloom을 수정하기 때문에, 같은 날 안에서 갓 핀 꽃이 또 다음 칸을 피우는 연쇄가 일어난다. 예시 1을 한 번 따라가보면 다음과 같다.
초기: 1일째 끝나야 할 모습:
0 0 0 0 1 0
0 1 0 → 1 1 1
0 0 0 0 1 0
(1,1)만 1이니 4방향 4칸만 1로 바뀌어야 한다. 그런데 위 코드는 루프가 (1,1)을 처리한 뒤 (1,2)에 도착했을 때 방금 1로 바뀐 (1,2)를 또 보고 (0,2), (2,2)까지 1로 만들어버린다. 결국 한 번의 while 반복으로 거의 모든 칸이 피어버린다.
해결 — 오늘 시점 스냅샷과 내일 결과를 쓸 곳을 분리한다.
def solution(n, garden):
answer = 0
bloom = [row[:] for row in garden] # 2차원 깊은 복사
dr = [-1, 0, 1, 0]
dc = [0, 1, 0, -1]
while any(any(v == 0 for v in row) for row in bloom):
today = [row[:] for row in bloom] # 오늘의 스냅샷
for i in range(n):
for j in range(n):
if today[i][j]: # 읽기는 today
for d in range(4):
ni, nj = i + dr[d], j + dc[d]
if 0 <= ni < n and 0 <= nj < n:
bloom[ni][nj] = 1 # 쓰기는 bloom
answer += 1
return answer
2차원 깊은 복사 패턴
| 코드 | 결과 |
|---|---|
bloom = garden | 같은 객체 참조 (수정 동기화) |
bloom = garden[::] 또는 garden[:] | 외부 리스트만 복사, 내부 행은 공유 (얕은 복사) |
bloom = [row[:] for row in garden] | 행까지 새로 복사 (깊은 복사) |
2차원 배열을 안전하게 복사하려면 무조건 행 단위 컴프리헨션 패턴을 써야 한다. 시뮬레이션 문제에서 매우 자주 나오니 외워두면 좋다.
슬림화 포인트
처음에 if all(all(v == 1 for v in row) for row in garden): return 0 같은 초기 분기를 넣어뒀었는데, 아래의 while any(any(v == 0 ...))가 이미 이 케이스를 처리한다. 전부 1이면 while 진입을 안 하고 answer = 0 그대로 반환한다.
"이 분기, 빼도 답 같지 않은가?"
23차에 짚었던 슬림화 자기 점검 질문이다. 이번엔 피드백 1회 후 곧장 자력 삭제로 정리됐다.
한 줄에 K자를 적을 수 있는 메모장에 영어 단어를 순서대로 적는다. 단어 사이는 공백 1칸으로 구분하며, 줄 끝에 단어가 잘리지 않도록 다음 줄로 넘긴다. 단어 배열을 모두 적었을 때 몇 줄이 되는지 구한다.
처음 작성한 코드:
def solution(K, words):
answer = 0
current = 0
for word in words:
if (current + len(word) + 1) // K > 0: # 줄바꿈 판단
answer += 1
current = len(word)
else:
current += len(word) + 1
return answer
수업 중 질문: 글자 사이 공백을 잘 고려했는지 모르겠다고 했다.
자가 의심이 정확했다. 통과 답에 식 버그가 있다.
버그 1 — 마지막 줄 카운트 누락
answer는 줄바꿈이 일어날 때만 증가한다. 마지막 단어가 같은 줄에 들어가면(else) 그 줄은 세지 않고 끝난다.
solution(10, ["nice"])
# 정답: 1줄, 코드 결과: 0
버그 2 — 공백을 두 번 카운트
첫 단어 nice를 처리할 때 current = 0 + 4 + 1 = 5로 저장한다. nice 뒤에 공백 한 칸을 미리 붙인 형태다. 그런데 다음 단어 c를 추가할 때 또 current + len(word) + 1로 공백을 더한다.
K=5, ["ab", "c", "d"]로 따라가보면:
| 단계 | current | 판단 | 코드 결과 | 사람 직관 |
|---|---|---|---|---|
| ab 추가 | 0 → 3 | 같은 줄 | "ab_" 저장 | "ab" |
| c 추가 | (3+1+1)//5=1 → 줄바꿈 | c 새 줄 | "ab_" 끝 / "c" | "ab c" 같은 줄 (4칸) |
| d 추가 | (1+1+1)//5=0 → 같은 줄 | "cd" | 정답 1줄? 2줄? |
정답은 2줄 (ab_c 4칸 한 줄, d 한 줄)인데 코드는 1을 반환한다. 공백이 한 번 들어가야 할 자리에 두 번 카운트돼서 발생한 어긋남이다.
수정 코드
def solution(K, words):
answer = 1
current = 0
for word in words:
if current == 0: # 첫 단어
current = len(word)
elif current + len(word) + 1 > K: # 줄바꿈 필요
answer += 1
current = len(word)
else:
current += len(word) + 1 # 같은 줄에 공백 + 단어
return answer
세 가지가 동시에 해결된다.
answer = 1로 시작 — 단어가 있으면 줄은 무조건 1개 이상<= K 대신 > K로 명시적 비교 — // 같은 트릭 없이 직관적자연수 배열 arr에서 K개를 골라 (가장 큰 수 - 가장 작은 수) 차이가 최소가 되도록 한다. 처음 작성한 코드:
from itertools import combinations
def solution(arr, K):
coms = set(combinations(arr, K))
diff = 9999
for tp in coms:
diff = min(max(tp) - min(tp), diff)
return diff
수업 중 질문: 숫자 4개를 순서 상관없이 뽑아야 해서 combinations을 떠올렸는데, arr_len이 최대 1000까지 될 수 있다는 제약을 보고 시간복잡도가 괜찮을지 걱정된다고 했다.
자가 의심이 정확했다. C(1000, 50) ≈ 9 × 10⁸⁵는 우주의 원자 수(약 10⁸⁰)보다 많다. 메모리도, 시간도 절대 못 버틴다.
핵심 인사이트 — 정렬된 배열에서 K개 뽑을 때 차이 최소화는 무조건 연속된 K개다
예: 정렬된 [4, 6, 9, 9, 11, 19]에서 K=4
| 뽑은 4개 | max - min |
|---|---|
[4, 6, 9, 9] (연속) | 5 |
[6, 9, 9, 11] (연속) | 5 |
[9, 9, 11, 19] (연속) | 10 |
[4, 6, 9, 11] (비연속) | 7 |
비연속으로 뽑으면 어딘가 더 먼 수가 끼게 되니 차이가 같거나 더 커진다. 그래서 정렬 + 슬라이딩 윈도우로 풀면 된다.
수정 코드
def solution(arr, K):
arr_tmp = sorted(arr) # 원본 보존
answer = arr_tmp[K-1] - arr_tmp[0] # 첫 윈도우 초기값
for i in range(1, len(arr_tmp) - K + 1): # 둘째 윈도우부터
diff = arr_tmp[i + K - 1] - arr_tmp[i]
answer = min(answer, diff)
return answer
시간복잡도 O(n log n). 정렬 비용이 전부다.
한 코드에 세 가지 정착 패턴이 동시 발현
| 패턴 | 정착 회차 | 본 코드에서 |
|---|---|---|
| 슬라이딩 윈도우 첫 윈도우 미리 계산 | 20차 자기 진단 약점 | answer = arr_tmp[K-1] - arr_tmp[0] 자력 작성 |
| range 시작값을 1로 (둘째 윈도우부터) | 14차 약점 | range(1, ...) 자력 작성 |
| 원본 보존 (sorted 사용, sort()는 원본 변경) | 16차 정착 | sorted(arr) 자력 선택 |
1부터 n까지 적힌 카드 뭉치를 반으로 나눠 교대로 섞는 과정을 mix번 반복한 뒤, 아래에서 k번째 카드를 구한다. 디버깅 유형(한 줄 수정)이고 본인이 자력으로 정답을 찾았다.
원본 버그:
card_a, card_b = [0 for _ in range(n//2)], [0 for _ in range(n//2)]
for i in range(0, n):
if i < n//2:
card_a[i] = card[i]
else:
card_b[i] = card[i] # 버그: card_b 크기는 n//2인데 i가 n//2~n-1 들어감
본인 진단: "후반 1/2를 저장하는 배열 b에 인덱스를 원본 배열 그대로 넣어서 에러가 나는 것으로 보임."
card_b의 크기는 n//2(=3)인데 i가 3, 4, 5일 때 card_b[i]로 접근하면 IndexError가 난다.
수정: card_b[i - (n//2)] = card[i]
i=3 → card_b[0], i=4 → card_b[1], i=5 → card_b[2]로 후반부를 0부터 다시 채운다. 한 라인 안에서 두 토큰(i와 괄호 보정)을 동시에 바꾼 디버깅이지만 "한 줄 수정" 제약은 코드 라인 단위라 그대로 통과한다.
4×4 격자의 가장 왼쪽 위에서 가장 오른쪽 아래까지 오른쪽 또는 아래로만 이동하면서 코인을 최대로 모은다. 디버깅 한 줄 유형이고 본인이 자력 정답을 찾았다.
원본 버그:
else:
coins[i][j] = board[i][j] + max(coins[i][j], coins[i-1][j-1])
# max 안: 자기 자신(0) vs 대각선(도달 불가)
수정: max(coins[i-1][j], coins[i][j-1])
오른쪽 또는 아래로만 이동하니까 (i, j)에 도달하기 직전 위치는 위 (i-1, j) 또는 왼쪽 (i, j-1)이다. 그중 큰 누적값을 골라 현재 칸 값에 더한다.
coins[0]: [6, 13, 14, 16]
coins[1]: [9, 18, 21, 30]
coins[2]: [15, 22, 27, 32]
coins[3]: [22, 25, 29, 38] ← 정답 38
26차에 학습한 DP 마지막 동작 패턴이 자력 발동한 형태다. 26차에선 힌트가 필요했는데 이번엔 디버깅 문제에서 의도를 스스로 짚어냈다.
4×4 종이를 가로 또는 세로축에 평행한 격자선을 따라 한 번 접었을 때, 만나는 두 칸의 합이 최대가 되도록 한다. 디버깅 한 줄 유형이다.
원본 코드:
for i in range(4):
for j in range(4):
for k in range(j + 1, 4, 2): # step 2
answer = max(answer,
max(grid[i][j] + grid[j][k],
grid[i][j] + grid[k][i])) # 9번 라인
수업 중 질문: 맨 안쪽 for의 step 2가 처음엔 이해되지 않아 숫자를 대입해보면서 의미를 파악하려고 했다고 했다.
step 2의 의미 — (j, k) 쌍은 한 번 접으면 만날 수 있는 두 인덱스 쌍을 만든다. 합이 홀수일 때만 가능하다.
| j | range(j+1, 4, 2) | (j, k) 후보 | j+k |
|---|---|---|---|
| 0 | [1, 3] | (0,1), (0,3) | 1, 3 |
| 1 | [2] | (1,2) | 3 |
| 2 | [3] | (2,3) | 5 |
| 3 | [] | — | — |
j+k가 짝수이면 접는 선이 칸 한가운데에 떨어져 물리적으로 접을 수 없다. step 2가 이 제약을 자동으로 걸러낸다.
9번 라인의 진짜 버그 — 인덱스가 어긋남
(j, k) 쌍을 받은 다음, 어떻게 활용해야 만나는 두 칸이 되는지 정리하면 다음 두 경우다.
| 접는 방향 | 두 칸 좌표 | 공통 조건 |
|---|---|---|
| 세로 접기 (같은 행에서) | (i, j) ↔ (i, k) | 행 같음 |
| 가로 접기 (같은 열에서) | (j, i) ↔ (k, i) | 열 같음 |
원본 코드는:
grid[i][j] + grid[j][k] — (i, j)와 (j, k)는 같은 행도 같은 열도 아니다. 행이 어긋남grid[i][j] + grid[k][i] — (i, j)와 (k, i)도 마찬가지로 열이 어긋남수정: grid[i][j] + grid[i][k], grid[j][i] + grid[k][i]
한 라인 안에서 4개 토큰(j→i, j→i, i→j, i→j)을 동시에 바꿔야 한다. 4번 카드 섞기에 이어 한 라인 안 다중 토큰 보정이 2회 연속 성공한 형태다.
출제자가 1~K 중 하나의 자연수를 정한다. 참가자가 수를 부르면 출제자는 자기 수보다 작을 때 "UP", 클 때 "DOWN", 같을 때 "RIGHT"이라고 한다. 주어진 게임 진행에서 현재 정답이 될 수 있는 숫자가 몇 개인지 구하는 빈칸 채우기 문제다.
본인이 채운 풀이:
def solution(K, numbers, up_down):
left = 1
right = K
for num, word in zip(numbers, up_down):
if word == "UP":
left = max(num, left)
elif word == "DOWN":
right = min(num, right)
elif word == "RIGHT":
return 1
return right - left - 1
본인의 풀이 흐름 — 처음엔 left = num + 1, right = num - 1로 정확히 1씩 보정하려 했다. 그런데 예시 2번이 "2 UP" 다음 "1 UP"으로 들어와서 단순 대입을 했더니 left가 3에서 2로 회귀하면서 망가졌다고 했다. 그래서 +1을 빼고 max(num, left)로 바꾼 뒤, 마지막에 right - left - 1로 양 끝을 빼는 방식으로 우회했다고 했다.
자가 분석이 정확한 방향이었지만 검증 시 한 가지가 더 어긋난다.
반례
K = 10
numbers = [5]
up_down = ["DOWN"]
# 정답: 1, 2, 3, 4 → 4개
# 코드: right = 5, left = 1 → 5 - 1 - 1 = 3 (틀림)
right - left - 1은 left와 right가 둘 다 후보 바깥 경계값이어야 의미가 맞는 공식인데, 초기값 left=1, right=K는 본인이 후보가 될 수 있는 값들이다. left가 한 번도 안 바뀌면 1이, right가 한 번도 안 바뀌면 K가 후보에서 빠져버린다.
해법 — 빈칸 채우기라 초기값을 못 바꾸므로 max에 +1을 다시 끼워 넣는다
if word == "UP":
left = max(num + 1, left) # +1 다시 추가
elif word == "DOWN":
right = min(num - 1, right) # -1 다시 추가
elif word == "RIGHT":
return 1
return right - left + 1 # 양 끝 포함
max(num + 1, left)로 감싸면 "2 UP 다음 1 UP" 같은 약한 정보도 자연히 무시된다. left가 3에서 1+1=2로 후퇴할 일이 없다 (max로 보호되니까).
검산:
solution(10, [5], ["DOWN"])
# right = min(4, 10) = 4
# left = 1
# 4 - 1 + 1 = 4 ✓
수업 중 메타 정리 (본인 발화): "처음 생각한 거랑 max 아이디어 합쳤어야 했네."
이게 시험장 디버깅의 핵심 사고다. 첫 가설 +1/-1이 틀린 게 아니라 단순 대입 때문에 깨졌던 것이고, max 도입이 그 깨짐의 보호막이었다. 가설을 통째로 버릴 게 아니라 깨진 원인만 찾아 합치면 답이 나온다.
27차에 2건(시계, 소수), 오늘 7번 UP/DOWN으로 누적 3건이 됐다. 모두 본인이 통과 처리받은 답에 식 자체의 버그가 숨어있던 케이스다.
| 회차 | 문제 | 어떤 입력에서 깨지나 |
|---|---|---|
| 27차 9번 | 시계 각도 | m=0과 m=30 외 모든 분 |
| 27차 10번 | 소수 세제곱 | a > 8 또는 b 큰 경우 |
| 28차 7번 | UP/DOWN | 한 방향만 들어오는 입력 ([5], DOWN 등) |
오늘 의미 있게 달라진 건, 2번·3번·7번에서 본인이 통과한 답에 자가 의심을 자발적으로 발화했다는 점이다.
자가 의심이 자발적으로 작동하기 시작하면, 같은 함정이 시험장에서도 잡힌다. 풀이 후 한 박자만 두고 극단 입력 하나만 상상하는 습관이 D-2 환기 포인트다.
| 상상해볼 극단 입력 | 잡히는 함정 |
|---|---|
| 빈 입력 / 1개짜리 입력 | 마지막 줄 카운트 누락, 초기값 모순 |
| 한 방향만 들어오는 시퀀스 | 한쪽 변수가 업데이트 안 되는 경우 |
| 최대 크기 입력 | TLE, 메모리 초과 |
| 경계값 (a=b, K=1, 단일 원소) | off-by-one, 빈 슬라이스 |
구름EDU 6회차 풀타임 모의고사 77분에 9문제 자력 풀이, 추정 800점대 안정으로 합격선 한참 위였다.
시뮬레이션 동기성 패턴(2차원 깊은 복사 포함)을 신규 학습했고, 슬라이딩 윈도우·DP·슬림화·한 라인 다중 토큰 보정 4가지의 자력 발현/정착을 확인했다.
내일은 5차 기출 미해결 구현 3개와 구현 집중 연습을 진행할 예정이다.