26차(5/12)에서 5차 기출 7문제 스터디 풀이를 복습하고 오답 총복습을 진행했다. DP 마지막 동작 패턴과 range 역순 3가지 방식을 정리했고, 90도 회전·스택·값 기반 중복 체크 함정의 정착을 재확인했다. D-4 기준 주요 약점 없이 90분 풀타임 모의고사 단계로 진입한 상태였다.
구름EDU 기출 4회차를 90분 풀타임 모의고사로 진행한 뒤, 통과했지만 찝찝한 문제와 못 푼 2문제를 검산 모드로 복기했다. 시험 환경을 흉내내기 위해 검색·AI 도움 없이 90분 안에 풀어내는 형태로 진행했다.
COS Pro 1급 Python / Phase 3: 실전 대비
| 항목 | 결과 |
|---|---|
| 소요 시간 | 73분 (90분 내) |
| 통과 문제 | 8/10 |
| 못 푼 문제 | 6번 자아도취 수(빈칸), 8번 n번째 작은 수(함수 작성) |
| 추정 점수 | 800점+ (합격선 600 한참 위) |
못 푼 2개에 시간을 더 매달리지 않고 마무리한 것이 시험장 판단력 측면에서 의미가 있었다. 실제 시험에서도 모르는 문제에 30분 매달리는 것보다 손절하고 다른 문제로 넘어가는 것이 합격선 확보에 유리하다.
A, E, I, O, U 다섯 알파벳으로 만들 수 있는 길이 1~5의 모든 단어를 사전 순으로 나열했을 때, 주어진 word의 위치를 return하는 문제다. 한 줄을 수정하는 디버깅 유형이다.
def create_words(lev, s):
global words
VOWELS = ['A', 'E', 'I', 'O', 'U']
words.append(s)
for i in range(0, 5):
if lev < 5:
create_words(lev, s + VOWELS[i]) # 버그: lev 그대로 0
버그: lev가 계속 0이라 lev < 5가 영원히 True → 무한 재귀가 발생한다.
수정: create_words(lev + 1, s + VOWELS[i]) — 다음 호출에 깊이 한 단계 더한 값을 인자로 넘긴다.
수업 중 질문: 함수 안에서 자기 자신을 호출하는 형태 자체가 어렵다고 했다. lev += 1을 다른 줄에 넣어봤더니 결과가 달라서 이해되지 않았다고 했다.
재귀의 핵심 멘탈 모델
각 호출은 자기만의 독립된 lev, s를 가진다. create_words(lev+1, ...)은 새로운 호출에 새 값을 인자로 넘기는 것이지, 위쪽 호출의 lev를 바꾸는 게 아니다.
create_words(0, '') ← 이 호출의 lev는 0, 영원히 0
↓ 새 호출 만듦
create_words(1, 'A') ← 이 호출의 lev는 1, 위의 0과 별개
↓
create_words(2, 'AA') ← lev=2, 또 별개
Java로 비유하면 return sum(n-1) + n 같이 새 값을 인자로 넘기는 형태와 같다. 위쪽 호출의 n을 바꾸는 게 아니다.
lev += 1을 다른 줄에 넣으면 어떻게 되는가
def create_words(lev, s):
words.append(s)
lev += 1 # 여기 넣으면 길이 5짜리가 누락된다
for i in range(0, 5):
if lev < 5:
create_words(lev, s + VOWELS[i])
lev=4로 진입(s='AAAA')하면 lev += 1로 lev=5가 되고 if lev < 5가 False가 된다. 자식 호출이 0번이라 'AAAAA' ~ 'AAAAU' 다섯 단어가 누락된다. 그래서 결과가 달랐던 것이다.
재귀 빈칸/디버깅 체크리스트
if lev < 5lev+1, s + 글자처럼 뭔가 한 칸 나아가야 한다. 안 그러면 무한 루프다hour:minute일 때 아날로그 시계의 시침과 분침이 이루는 각도를 구하는 문제다. 본인이 제출한 답:
def solution(hour, minute):
h_angle = hour / 12 * 360 % 360
m_angle = (60-minute) / 60 * 360 % 360
answer = max(h_angle, m_angle) - min(h_angle, m_angle)
return "{:.1f}".format(answer)
제출은 통과했지만 식에 버그가 3개 있다. 테스트가 약해서 우연히 통과한 케이스다.
버그 1: 시침의 분 기여도 누락
3:30일 때 시침은 3을 정확히 안 가리키고 3과 4 사이에 있다. 분침이 한 바퀴 돌 때마다 시침도 30° 움직이므로, 시침에 minute * 0.5를 더해야 한다.
| 식 | 3:30 시침 |
|---|---|
hour / 12 * 360 | 90° (틀림) |
hour * 30 + minute * 0.5 | 105° (맞음) |
버그 2: 분침이 반대로 돈다
(60-minute)/60 * 360은 분침을 반시계 방향으로 돌린다.
| 분 | 본인 식 | 정답 minute * 6 |
|---|---|---|
| 0 | 0 | 0 |
| 15 | 270 | 90 |
| 30 | 180 | 180 |
| 45 | 90 | 270 |
m=0과 m=30에서만 우연 일치한다. 4회차 테스트가 이 두 케이스만 있어서 통과한 것으로 보인다.
버그 3: 큰 각도 처리 누락
9:00일 때 시침 270°, 분침 0°. 보통 "이루는 각"은 작은 쪽(90°)을 답한다. 본인 식은 270을 반환한다. min(diff, 360 - diff)로 마무리해야 안전하다.
정답 코드와 외울 공식
def solution(hour, minute):
h_angle = hour * 30 + minute * 0.5
m_angle = minute * 6
diff = abs(h_angle - m_angle)
answer = min(diff, 360 - diff)
return "{:.1f}".format(answer)
| 공식 | 내용 |
|---|---|
| 시침 각도 | hour * 30 + minute * 0.5 |
| 분침 각도 | minute * 6 |
| 두 침 사이 각 | min(\|h-m\|, 360-\|h-m\|) |
이 유형은 공식을 모르면 풀 수 없다. 외워두는 게 답이다.
세 자리 자아도취 수: 153 = 1³ + 5³ + 3³. 각 자릿수를 k 제곱해서 더한 값이 원래 수와 같은 자연수다. 빈칸 2개를 채우는 문제였는데 풀지 못했다.
while current != 0:
calculated += power(current % 10, k) # 빈칸 1
current //= 10 # 빈칸 2
핵심 패턴 — % 10 + // 10 한 쌍
| 연산 | 의미 |
|---|---|
n % 10 | 마지막 자리 한 개 추출 |
n // 10 | 마지막 자리 한 개 제거 |
이 두 개가 짝지어 돌아가야 자릿수 분해가 된다. 153 추적:
| current | current % 10 | calculated 누적 | current //= 10 |
|---|---|---|---|
| 153 | 3 | 0 → 27 (3³) | 15 |
| 15 | 5 | 27 → 152 | 1 |
| 1 | 1 | 152 → 153 | 0 |
| 0 | (탈출) |
수업 중 질문: while 조건이 current > 0이 아니고 != 0이라서 빈칸에 탈출 조건을 만들어야 하는데 어떻게 만들어야 할지 모르겠다고 했다.
이게 가장 중요한 오해 지점이었다. 탈출 조건을 빈칸에 넣는 게 아니다. current //= 10이라는 데이터 변형이 매 반복마다 current를 줄여 결국 0에 도달하게 만들고, 그 결과로 current != 0이 자연히 False가 되는 것이다. 탈출은 데이터 변형의 부산물이지 빈칸의 목적이 아니다.
100부터 시작해도 → 10 → 1 → 0이 되니까 3번 만에 탈출한다. 999부터 시작해도 → 99 → 9 → 0이라 마찬가지로 3번이다.
또 current % 100을 끝 두 자리(53)로 가져온다는 점도 함께 짚었다. 한 자리만 뽑으려면 무조건 % 10이다.
자주 나오는 응용:
# 외워둘 자릿수 분해 템플릿
while n != 0:
digit = n % 10
# digit으로 뭔가 처리
n //= 10
숫자 카드 배열로 만들 수 있는 모든 수를 작은 순으로 정렬했을 때, n이 몇 번째인지 구하는 문제다.
본인 시도는 Counter로 카드와 n의 자릿수를 비교해 다르면 -1을 반환하는 부분까지 진행했고, 그 뒤가 막혔다. 핵심 도구는 itertools.permutations다.
from itertools import permutations
from collections import Counter
def solution(card, n):
digits = [int(c) for c in str(n)]
if Counter(card) != Counter(digits):
return -1
perms = sorted(set(permutations(card)))
target = tuple(digits)
return perms.index(target) + 1
combinations vs permutations
| 도구 | 의미 | 예: [1,2,3]에서 2개 |
|---|---|---|
combinations | 순서 무관 조합 | (1,2), (1,3), (2,3) |
permutations | 순서 있는 순열 | (1,2), (1,3), (2,1), (2,3), (3,1), (3,2) |
10차에서 배운 combinations의 동생격이다.
set이 필요한 이유: permutations는 위치(인덱스) 기반이라 [1,2,1,3]처럼 같은 숫자가 있으면 동일한 순열이 여러 번 나온다. 첫 번째 1(card[0])과 두 번째 1(card[2])을 다른 카드로 취급하기 때문이다. 값으로 봤을 땐 같은 순열이니 set()으로 묶어야 정확한 등수가 나온다.
permutations를 손에 익히기 위해 출제된 응용 문제다.
from itertools import permutations
def solution(cards):
digits = []
for perm in set(permutations(cards)):
num = int(''.join(map(str, perm)))
if num % 2 == 0:
digits.append(num)
return max(digits, default=-1)
7개 테스트 케이스 모두 통과했다. 첫 학습 직후 응용 문제를 자력으로 푼 경우다.
튜플 → 정수 변환: int(''.join(map(str, perm))) 패턴이다. (3, 1, 2)를 312로 만든다.
max(default=-1): max는 빈 리스트에서 에러를 내는데, default 인자로 빈 시퀀스일 때 반환할 값을 지정할 수 있다.
수업 중 슬림화 포인트: 처음에 sorted(set(permutations(cards)))로 정렬했지만, max()로 최댓값만 뽑을 거면 정렬이 필요 없다. 정렬은 O(n log n) 비용이 들지만 결과엔 영향이 없다.
"이 연산 정말 필요한가? 빼도 결과 같지 않은가?"
함수 작성 후 자기 점검 단계로 들어가야 할 메타 질문이다. 23차에 짚었던 슬림화 의식 영역으로, 27차에 1회 재발했다.
a 이상 b 이하 자연수 중 소수의 제곱수와 세제곱수의 개수의 합을 구하는 문제다. 본인이 제출한 답:
def solution(a, b):
answer = 0
nums = [2] # 소수 2를 미리 넣음
for n in range(3, b+1):
i = 2
for j in range(i, n):
if n % j == 0:
break
if j == n-1:
if a<=n*n<=b:
nums.append(n)
if a<=n*n*n<=b:
nums.append(n)
answer = len(nums)
return answer
수업 중 질문: 소수 2를 미리 넣고 3부터 보기 시작하는 방식이 괜찮은지 물어봤다.
답은 "버그다. 테스트가 약해서 통과한 것"이다. nums = [2]로 2를 무조건 카운트에 포함하지만, 2² (=4) 또는 2³ (=8)이 [a,b] 범위에 있는지 확인하지 않는다.
| a | b | 정답 | 본인 코드 |
|---|---|---|---|
| 6, 30 | 4 | 4 (우연 일치) | |
| 10, 30 | 2 | 3 (2 헛카운트) | |
| 50, 100 | 0 | 1 (2 헛카운트) |
근본 원인은 소수 판별 루프가 range(2, n)이라 n=2에선 inner loop가 비어버려 일반 루프에 못 넣은 것이다. 소수 판별을 함수로 분리하면 자연 처리된다.
수업 중 잘한 부분: 처음에 13라인과 15라인을 or로 연결했다가 답이 달라서 예시(3은 제곱과 세제곱 모두 범위 안)를 보고 분리한 부분이다. or로 묶으면 한 번만 카운트되지만 별도 if로 분리하면 둘 다 만족할 때 두 번 카운트된다. 직접 예시를 추적해서 검증한 것은 24차에 정착된 검증 습관이 지속되고 있는 신호다.
추가 이슈 — 시간복잡도
b는 최대 10⁹까지 가능한데 for n in range(3, b+1)로 루프를 돌면 절대 끝나지 않는다.
n² ≤ b이려면 n ≤ √b면 충분하니까 루프 상한을 √b로 줄일 수 있다.
b=10⁹여도 √b ≈ 31,623으로 한순간이다. 23차에 배운 √n 최적화를 소수 판별 자체에도 적용해야 한다.
정답 코드:
def is_prime(n):
if n < 2:
return False
if n == 2:
return True
if n % 2 == 0:
return False
for i in range(3, int(n**0.5) + 1, 2):
if n % i == 0:
return False
return True
def solution(a, b):
answer = 0
limit = int(b ** 0.5) + 1
for n in range(2, limit + 1):
if is_prime(n):
if a <= n * n <= b:
answer += 1
if a <= n * n * n <= b:
answer += 1
return answer
오늘 4회차에서 두 건 발견했다.
19차에서 짚었던 "주어진 테스트 전부 통과지만 반례에서 깨짐" 패턴이 이번엔 본인이 통과 처리받은 답에서 재발한 형태다. 실제 시험은 테스트가 더 엄격할 수 있으니, 풀이 후 반례를 직접 상상해보는 습관이 필요하다.
10번 nums=[2]에 대해 "이래도 되는걸까?"라고 자가 의심한 것 자체는 좋은 시작이다. 통과한 답에도 의심 가능한 메타인지가 발현된 것이다. 시험장에서는 시간 제약 때문에 모든 답을 의심할 순 없지만, 핵심 식만큼은 "반례 한 개 떠올려보기"를 의식적으로 끼워 넣자.
구름EDU 4회차 풀타임 모의고사 73분에 8/10 통과, 추정 800점대로 합격선 한참 위였다.
신규 학습 5건(재귀 멘탈 모델, 시계 각도 공식, 자릿수 분해 패턴, itertools.permutations, max default 인자)을 정리했다.
내일 6회차 풀타임 모의고사 예정이다.