정답률 0.95퍼의 무시무시한 문제다.
게임 개발자인 베로니는 개발 연습을 위해 다음과 같은 간단한 카드 짝맞추기 보드 게임을 개발해 보려고 합니다.
게임이 시작되면 화면에는 카드 16장이 뒷면을 위로하여 4 x 4 크기의 격자 형태로 표시되어 있습니다. 각 카드의 앞면에는 카카오프렌즈 캐릭터 그림이 그려져 있으며, 8가지의 캐릭터 그림이 그려진 카드가 각기 2장씩 화면에 무작위로 배치되어 있습니다.
유저가 카드를 2장 선택하여 앞면으로 뒤집었을 때 같은 그림이 그려진 카드면 해당 카드는 게임 화면에서 사라지며, 같은 그림이 아니라면 원래 상태로 뒷면이 보이도록 뒤집힙니다. 이와 같은 방법으로 모든 카드를 화면에서 사라지게 하면 게임이 종료됩니다.
게임에서 카드를 선택하는 방법은 다음과 같습니다.
다음은 카드가 몇 장 제거된 상태의 게임 화면에서 커서를 이동하는 예시입니다.
아래 그림에서 빈 칸은 이미 카드가 제거되어 없어진 칸을 의미하며, 그림이 그려진 칸은 카드 앞 면에 그려진 그림을 나타냅니다.
<예시는 생략하겠습니다.>
현재 카드가 놓인 상태를 나타내는 2차원 배열 board와 커서의 처음 위치 r, c가 매개변수로 주어질 때, 모든 카드를 제거하기 위한 키 조작 횟수의 최솟값을 return 하도록 solution 함수를 완성해 주세요.
입력
board : [[1,0,0,3],[2,0,0,0],[0,0,0,2],[3,0,1,0]]
r : 1
c: 0
board : [[3,0,0,2],[0,0,1,0],[0,1,0,0],[2,0,0,3]]
r : 0
c: 1
출력
14
16
입출력 예 #2
위 게임 화면에서 모든 카드를 제거하기 위한 키 조작 횟수의 최솟값은 16번 입니다.
문제를 읽으면서 이걸 도대체 어떻게? 라는 생각이 강하게 들었다. 그 이유는 아래와 같은데
←, ↑, ↓, →
이동과 ctrl + ←, ↑, ↓, →
(엑셀에서의 그것) , 카드를 첫번째로 뒤집는 것, 카드를 뒤집은 상태에서 다시 뒤집는 것. 정말 많다.문제 독해를 다시 천천히 해보자. 그러면 아래와 같은 점때문에 풀 수 있어 보인다.
4x4
, 16칸으로 매우 매우 작다. 완전탐색이 가능해보인다. 이쯤되면 한가지 풀이 밖에 없다. BFS다.
다만 구현할 부분이 매우 많은 BFS 풀이일뿐이다. 개인적인 생각으로 삼성 기출 문제를 열심히 공부하신 분들이라면 어렵지 않게 구현할 수 있을거라고 생각한다.
저는 삼성 기출문제 공부를 잘 안했습니다.
일단 커서의 위치를 기점으로 가능한 행동을 나눠보자.
상,하,좌,우
로 움직인다.ctrl + 상,하,좌,우
로 움직인다.Enter
를 눌러 카드를 뒤집는다.정리해보면 생각보다 간단하다. 각각의 변화하는 상태를 그래프에서의 노드로 보고 위에서 적은 3가지 행동을 모두 하는 완전탐색을 수행한다. 이 과정에서 최소 횟수를 구하니 BFS다.
이제 생각할 건 하나다. 매 탐색마다 어떤것이 변화할까?
일단 커서가 매번 움직이니, 커서의 위치는 변한다. 또, 단순히 탐색하는것이 아니라 격자에 변화를 주기 때문에 격자의 상태가 변화한다.
여기까지는 일반적이다. 그럼 이 문제에서 특수한 상황은 카드를 뒤집는 행동이다.
- 이번이 처음 뒤집는 카드라면, 그 카드를 기록한다.
- 이미 뒤집은 카드가 있다면, 그 카드와 지금 카드를 비교한다.
그러면 현재 카드를 뒤집었는지(False=0 or True=1장 뒤집었는지) 와 카드를 뒤집었다면 뒤집은 카드의 위치 역시 변한다.
단순히 변한다고만 해서 기록해줘야 하는 것은 아니다. 기록해줘야 하는 조건은 특정 조건이 없을때 탐색을 생략한다거나 중복으로 탐색하게 된다면, 그 특정 조건은 필요한 것이다.
예를 들어보자.
Enter
행동은 카드를 뒤집는 행동이다. 카드를 뒤집는것을 기록을 해주지 않는다면, visit
배열 안에 뒤집기 전의 상태가 그대로 들어가 있기 때문에, Enter
행동을 수행할 수 없다.
중복적인 탐색을 방지하는게 visit의 역할이기 때문이다.
정리하자면, 변화하는 상태는
위에서 적은 커서의 움직임과 변화하는 상태를 기반으로 코드를 작성해보자.
탐색 여부를 체크하는 visit
은 변화하는 상태가 많기 때문에, 고차원 list
로 구성하기가 곤란하다. set
을 이용하되 복잡도에 유의하자.
visit=set()
q=deque()
visit.add(초기격자의 상태,커서의 위치,카드를 뒤집지 않음,(-1,-1))
q.add(초기격자의 상태,커서의 위치,카드를 뒤집지 않음,(-1,-1),조작 횟수)
while q:
보드의 상태,기존 커서의 위치,카드를 뒤집었는지,뒤집었다면 위치,횟수=q.popleft()
for 상,하,좌,우 네 방향으로 :
새 커서 위치=방향별로 한칸 움직인 좌표=기존 커서의 위치+1
if 격자를 벗어났다면:
continue
if 이전의 상태,새 커서 위치를 방문한적 있다면:
continue
이전의 상태,새 커서 위치를 방문으로 표시
이전의 상태,새 커서 위치,횟수+1를 다음에 탐색
for 상,하,좌,우 네 방향으로 ctrl 이동 :
# 격자가 이렇고 C가 커서 일때
# 0 0 0 0
# 2 0 0 2
# 0 0 0 0
# 0 0 0 C
# 왼쪽으로 간다면 (0,3), 위로 간다면 (3,1)
새 커서 위치=min(기존 커서의 위치에서 방향별로 끝,존재하는 카드의 끝)
if 이전의 상태,새 커서 위치를 방문한적 있다면:
continue
이전의 상태,새 커서 위치를 방문으로 표시
이전의 상태,새 커서 위치,횟수+1를 다음에 탐색
# 현재 한장은 뒤집은 상태에서 다른것 뒤집으려 할때
if 현재 카드를 뒤집은 상태라면 :
# 짝을 찾는데 성공
if 지금 위치의 카드와 이전에 뒤집었던 카드가 같다면:
격자에서 현재 카드 숫자를 제거함
if 지금 모든 카드를 제거했다면:
return 횟수+1
else 아직 끝낼수 없다면:
바뀐 격자의 상태,현재 커서위치,뒤집지 않음을 방문으로 표시
바뀐 격자의 상태,현재 커서위치,뒤집지 않음을 다음에 탐색
# 짝 맞추기 실패
else 지금 위치의 카드와 이전에 뒤집었던 카드가 다르다면:
이전 상태,현재 커서의 위치,뒤집지 않음을 방문으로 표시
이전의 상태,현재 커서의 위치,카드를 뒤집음,횟수+1을 다음에 탐색
else 카드를 하나도 안 뒤집은 상태라면 :
if 이전의 상태,카드를 뒤집음,현재 커서의 위치를 방문한적 있다면:
continue
이전의 상태,현재 커서의 위치,카드를 뒤집음을 방문으로 표시
이전의 상태,현재 커서의 위치,카드를 뒤집음,횟수+1를 다음에 탐색
구현이 꽤 복잡하지만 하나씩 보면, 커서의 행동별로 분류해서 전혀 복잡하지 않다. 신경써줄 부분은 ctrl+상,하,좌,우
이동에서 가까운 카드 혹은 끝 부분을 찾는 방법과 카드를 뒤집은 상태에서 카드 짝을 맞췄는지 아닌지 파악하는 부분이다.
다만 격자의 상태를 기록해야하는데, 탐색이 커지는 경우에는 매 탐색마다 deque
에 4x4
격자를 넣기에는 메모리를 걱정해줘야할 수 있으니, 4x4
격자를 직렬화하는 방식을 택했다.
def serialize(board) -> str:
ret = ''
for r in board:
for num in r:
ret += str(num)
return ret
이 경우에는 2차원 좌표인 (x,y) 접근이 아니라 1차원 idx 접근으로 해야하기 때문에 (x,y) -> idx로 변환해주는 함수가 필요하다.
def idxConverter(y:int, x:int) -> int:
return 4 * y + x
이 경우에 훨씬 편리한점이 생기는데, 바로 카드를 제거할때 격자 안에 있는 요소를 변경하는 것이 아니라 string.replace()
함수를 사용할 수 있다.
def switchTo0(s,num):
return s.replace(num,'0')
2021 카카오 신입공채 문제해설의 6번 풀이를 보면 카드별로 지우는 순서를 순열로 정하고 순서대로 최단거리를 수행 후 최단거리의 합 중에서의 최소를 구하는데, 음.. 개인적인 생각으로 해설이 항상 직관적이고 최적의 해를 제공하지는 않는 것 같다.
단적으로 내가 작성한 코드는 모든 테스트 케이스에 소요시간이 200ms를 넘지 않는데 순열을 이용한 코드는 3000ms도 소요될때가 있다. 물론 이 문제는 효율성까지 검사하는 문제는 아니다.
지워야하는 순서를 순열을 통해서 정하고,최소횟수를 구하는 방법보다 처음부터 최소횟수를 완전탐색으로 구현하는것이 알고리즘 초보 입장에서 더 구현하기 쉬워보이고 와닿는다.
이 과정에서 (1,1)에 있는 1번 카드와 (2,2)에 있는 1번 카드는 서로 다른 순서로 인식해야 한다.
from collections import deque
dy = [-1, 1, 0, 0]
dx = [0, 0, -1, 1]
def solution(board, r, c):
def isEnd(s) -> bool:
for c in s:
if c != '0':
return False
return True
def OOB(y, x) -> bool:
if (not 0 <= y < 4) or (not 0 <= x < 4):
return True
return False
def isOnEdgeByDirection(y, x, d) -> bool:
if d == 0:
if y == 0:
return True
elif d == 1:
if y == 3:
return True
elif d == 2:
if x == 0:
return True
elif d == 3:
if x == 3:
return True
return False
# 바뀌는 상황 :
# 카드판의 상황,현재 커서의 위치 , 현재 뒤집었는지? 아니면 안뒤집엇는지 ( 2장째 기다리는중인지 뒤집었다면 커서 위치)
# 구하는 것 : 최소 회수 이동
def serialize(board) -> str:
ret = ''
for r in board:
for num in r:
ret += str(num)
return ret
def switchTo0(s:str,num:str) -> int:
return s.replace(num,'0')
def idxConverter(y, x) -> int:
return 4 * y + x
visit = set()
# 보드의 상태 ,커서의 위치 , 커서가 카드를 뒤집었는지 , 뒤집힌 카드가 있다면 그 위치
visit.add((serialize(board), (r, c), False, (-1, -1)))
q = deque()
q.append((serialize(board), r, c, False, -1, -1, 0))
while q:
b, y, x, isFlipped, f_y, f_x, count = q.popleft()
# 모든 카드를 뒤집었는지
# 여기서 체크하면 시간 더걸림
# if isEnd(b):
# return count
for k in range(4):
ny, nx = y + dy[k], x + dx[k]
if OOB(ny, nx):
continue
while b[idxConverter(ny, nx)] == '0' and not isOnEdgeByDirection(ny, nx, k):
ny, nx = ny + dy[k], nx + dx[k]
if (b, (ny, nx), isFlipped, (f_y, f_x)) in visit:
continue
visit.add((b, (ny, nx), isFlipped, (f_y, f_x)))
q.append((b, ny, nx, isFlipped, f_y, f_x, count + 1))
# 상하좌우 네방향 이동
for k in range(4):
ny, nx = y + dy[k], x + dx[k]
if OOB(ny, nx):
continue
if (b, (ny, nx), isFlipped, (f_y, f_x)) in visit:
continue
visit.add((b, (ny, nx), isFlipped, (f_y, f_x)))
q.append((b, ny, nx, isFlipped, f_y, f_x, count + 1))
# 현재 한장은 뒤집은 상태에서 다른것 뒤집으려 할때
if isFlipped:
# 카드 짝 찾는데 성공하면
# 0으로 뒤집고
if b[idxConverter(f_y, f_x)] == b[idxConverter(y, x)] and (f_y, f_x) != (y, x):
b = switchTo0(b,b[idxConverter(f_y, f_x)])
# 여기서 끝낼수있음
if isEnd(b):
return count + 1
# print(b)
visit.add((b, (y, x), False, (-1, -1)))
q.append((b, y, x, False, -1, -1, count + 1))
# 짝은 안맞으면
else:
if (b, (y, x), False, (-1, -1)) in visit:
continue
visit.add((b, (y, x), False, (-1, -1)))
q.append((b, y, x, False, -1, -1, count + 1))
# 아직 뒤집지 않은 상태
# 이제 뒤집기
else:
if (b, (y, x), True, (y, x)) in visit:
continue
visit.add((b, (y, x), True, (y, x)))
q.append((b, y, x, True, y, x, count + 1))
백준 난이도로 환산하면 골드2 정도로 보인다.
은근히 테케에 빈틈이 많은 문제다.
첫 시도는 80점 내외로 맞고 조금씩 변경해 80->96->100점을 받을 수 있었다.