숫자 야구의 글자 버전인 Wordle이 요즘 대인기다. 숫자 야구랑 똑같아 보이지만 실제 존재하는 단어만 넣을 수 있다는 점 덕분에 해보면 은근히 재미있다. 허나 나의 몹쓸 개발자 근성 덕분에 이걸 하면서도 "이거 프로그램으로 짜면 바로 풀텐데.." 생각이 계속 들었다.. 그러니 짜 보도록 하자.
우선 단어 목록부터 가져오자. 리눅스나 맥 시스템에는 /usr/share/dict/words
에서 영단어 목록을 쉽게 얻을 수 있다. 파이썬의 리스트 축약 문법을 사용하면 간단하다.
# 영문자로만 구성된 5글자 단어만 대문자로 바꿔 모은다.
words = [w.upper() # (1)
for w in open('/usr/share/dict/words').read().splitlines() # (2)
if w.isalpha() and len(w) == 5 # (3)
]
upper()
는 문자열을 대문자로 바꿔 준다.read().splitlines()
를 통해 각 문자열 뒤에 따라오는 개행 문자(\r
)를 지워 준다.isalpha()
) 5글자인 (len(w) == 5
) 단어만을 걸러낸다.Wordle의 가장 중요한 부분은 정답과 추측이 주어졌을 때 어느 글자가 맞고 어느 글자는 위치가 틀렸는지 알려주는 로직이다. Wordle은 우선 위치까지 맞춘 글자들을 전부 녹색으로 표시하고, 정답의 나머지 글자들 중 맞았는데 위치만 틀린 글자들을 노란색으로 표시해 준다. 이 때 어떤 글자 X가 정답에 N번 출현하면, 추측에 나온 X는 N개까지 노란색으로 표시되고, 나머지는 회색으로 표시된다.
BANAL
인데 BIBLE
라고 추측한 경우. 이 때 첫 글자인 B
는 이미 녹색이 되어서 세 번째 글자인 B
는 회색으로 출력된다.BANAL
인데 ALLOY
라고 추측한 경우. L
는 정답에 한 번 나오는데 우리의 추측에는 두 번 들어있다. 이 때는 첫 번째로 찍은 L
만 노란색이고, 두 번째 L
은 회색으로 표시된다.먼저 정답이 answer
, 추측이 guess
문자열에 들어 있을 때 노란색이 될 수 있는 글자들의 후보를 찾아보자.
# 정답에서 노란색 후보가 될 수 있는 글씨들의 목록을 찾자.
misses = [a # (3)
for g, a in zip(guess, answer) # (1)
if g != a # (2)
]
zip(guess, answer)
는 두 문자열에서 한 글자씩 떼내어 쌍을 만들어 준다. g
, a
는 각각 guess
, answer
의 첫 글자로 시작하고, 두 번째 반복에서는 두 번째 글자가 된다.g == a
인 경우 초록색으로 표시되니까 제외해야 한다.a
, 다시 말해 정답에 포함된 글자만을 모은다.그리고 나면 추측의 각 문자를 보며 어떤 색이 될지를 계산할 수 있다.
color = []
for g, a in zip(guess, answer):
if g == a:
color.append('G')
elif g in misses: # (1)
misses.remove(g) # (2)
color.append('Y')
else:
color.append('.')
misses
는 정답 중 녹색이 아닌 글자들을 담는 리스트다. 우리가 추측한 글자 g
가 여기 있다는 것은 위치만 틀렸다는 것이기 때문에, 이 글자는 노란색이 된다.remove
는 글자가 여러 개 있을 경우 하나만 지워 준다.이것을 모아 다음과 같은 함수를 작성할 수 있다.
def check(guess, answer):
misses = [a for g, a in zip(guess, answer) if g != a]
color = []
for g, a in zip(guess, answer):
if g != a and g in misses:
misses.remove(g)
color.append('Y') # 노란색 Y
elif g == a:
color.append('G') # 녹색 G
else:
color.append('.') # 회색은 .로 표시
return ''.join(color)
간단 테스트:
>>> wordle.check('BIBLE', 'BANAL')
'G..Y.'
>>> wordle.check('ALLOY', 'BANAL')
'YY...'
다음으로 어떤 단어를 찍을지 정하는 첫 단계는 우리가 아는 단어들 중 정답일 가능성이 있는 것들만 골라내는 것이다. 지금까지 플레이한 기록이 리스트 history
에 들어 있다고 하자. 이 리스트의 각 원소는 (guess, result)
이다. guess
는 우리가 찍은 단어, result
는 이 때 얻은 각 글자의 색깔이다. 예를 들어
이 플레이 기록은
[('LASER', 'YYY.G'),
('CHEER', '....G')]
로 저장하는 식이다.
어떤 단어가 정답일 가능성이 있는지 알려면, 이것이 정답이라고 가정했을 때 history
와 일치하는지 보면 된다. 리스트 축약을 이용하면 한 줄에 짤 수 있다. (80글자가 넘어가지만..)
candidates = [w for w in words if all(check(guess, w) == result for guess, result in history)]
check(guess, w) == result
는 w
가 정답일 때, guess
라고 추측해서 result
라는 결과를 얻었을지 확인한다.all()
은 입력으로 주어진 반복자(iterator)의 모든 원소가 참인 경우 참을, 아닌 경우 거짓을 반환한다. 여기서는 history
의 모든 (guess, result)
쌍에 대해 w
가 정답일 가능성이 있으면 참을 반환하게 된다.이제 답일 가능성이 있는 단어들을 다 알았다. 그러나 그렇다고 해서 그 중에서 가장 흔한 단어를 찍거나 해서는 6번의 기회 안에 답을 알아내지 못할 수 있다. 6번 안에 문제를 풀려면 한 번 추측을 할 때마다 정답 후보들(위의 candidates
)의 개수를 최대한 줄여야 한다.
어떻게 하면 매 번 추측마다 정답 후보들의 개수를 최대한 줄일 수 있을까? 정답 후보가 100개 있다고 하자. 어떤 단어 A
가 좋은 추측인지를 확인해 보려면 각 후보마다 A
가 어떤 결과를 얻을지를 각각 계산해 본다. 예를 들어 다음과 같은 결과를 얻었다고 하자.
* 후보 1이 정답일 경우: G..G.
* 후보 2이 정답일 경우: G..G.
* 후보 3이 정답일 경우: G..G.
* 후보 4가 정답일 경우: G..G.
.... (모두 같음)
* 후보 99가 정답일 경우: G..G.
* 후보 100이 정답일 경우: YYGGG
반면 단어 B
를 추측했을 경우 어떤 결과를 얻을지 계산해 보니 다음과 같았다고 가정하자.
* 후보 1이 정답일 경우: G..G.
.... (모두 같음)
* 후보 60이 정답일 경우: G..G.
* 후보 61이 정답일 경우: YYGGG
.... (모두 같음)
* 후보 100이 정답일 경우: YYGGG
A
와 B
중 어느 쪽을 찍는 게 더 유리할까? A
를 찍어서 운좋게 YYGGG
를 얻으면 다음에 바로 답을 맞출 수 있다. 하지만 G..G.
를 얻을 경우 후보의 개수가 100에서 99로 한 개 줄어들 뿐이다. 반면 B
를 찍으면 운이 좋으면 정답 후보의 개수를 40개로 줄일 수 있고, 최악의 경우에도 정답 후보의 개수를 60개로 줄일 수 있다. 운이 나쁘더라도 6번 안에 정답을 맞출 수 있는 확률은 B
가 더 높다고 볼 수 있다.
이 직관을 이용해 어떤 단어 G
가 얼마나 좋은 추측인지에 대한 기준을 다음과 같이 세워 보자.
G
가 어떤 결과를 얻을지를 계산한다.어떤 단어 guess
가 주어질 때, candidates
의 모든 후보에 대해 그 결과를 다음과 같이 계산할 수 있다.
[check(guess, cand) for cand in candidates]
파이썬 표준 라이브러리의 collections.Counter
에 이 결과를 넣으면 각 결과가 몇 번이나 나오는지 세어준다.
Counter(check(guess, cand) for cand in candidates)
이 중 최악의 경우 (같은 결과를 갖는 후보들이 제일 많은 경우) 를 찾기 위해서는 most_common()
함수를 사용하면 된다. 이들을 결합하면 다음과 같은 형태의 함수가 된다.
def largest_group_size(guess):
partition = Counter(check(guess, cand) for cand in candidates)
[(_, freq)] = partition.most_common(1)
return freq
이제 모든 단어 중 이 기준을 최적화하는 단어를 찾자. min()
을 쓰면 간단하다.
min(words, key=largest_group_size)
min()
, sorted()
등의 함수들은 key
인자에 주어진 원소의 우선순위를 계산하는 함수를 넘길 수 있도록 되어 있다. 그러면 주어진 목록의 원소를 비교하는 대신 각각의 우선순위를 계산하고 이들의 비교를 통해 최소 원소를 찾거나 정렬해준다.min()
의 첫 번째 인자가 candidates
가 아니라 words
(모든 단어) 이라는 점에 유의. 항상 정답 후보만 찍는 것이 제일 좋은 것은 아니다.결합하면 이런 함수가 된다.
def best_guess(history):
# 정답 후보 목록 구하기
candidates = [w for w in words
if all(check(guess, w) == result for guess, result in history)]
# 정답 후보가 하나인 경우 바로 반환
if len(candidates) == 1: return candidates[0]
# guess를 찍었는데 최악의 경우에 정답 후보 개수는?
def largest_group_size(guess):
partition = Counter(check(guess, cand) for cand in candidates)
[(_, freq)] = partition.most_common(1)
return freq
# 최악의 경우에도 정답 후보 크기가 가장 많이 줄어드는 단어를 구한다
return min(words, key=largest_group_size)
이제 이들을 모두 모아보면 다음과 같은 40줄 코드가 된다.
from collections import Counter
words = set(w.upper() for w in open('/usr/share/dict/words').read().splitlines() if w.isalpha() and len(w) == 5)
def check(guess, answer):
misses = [a for g, a in zip(guess, answer) if g != a]
color = []
for g, a in zip(guess, answer):
if g != a and g in misses:
misses.remove(g)
color.append('Y') # 노란색 Y
elif g == a:
color.append('G') # 녹색 G
else:
color.append('.') # 회색은 .로 표시
return ''.join(color)
def best_guess(history):
if len(history) == 0: return ['OATES', 'AIRES', 'ARIES', 'NEARS', 'AROSE', 'SNARE', 'SANER', 'REALS', 'LANES', 'SADIE']
candidates = [w for w in words if all(check(guess, w) == result for guess, result in history)]
if len(candidates) <= 1: return candidates
def largest_group_size(guess):
partition = Counter(check(guess, cand) for cand in candidates)
[(_, freq)] = partition.most_common(1)
return freq
return sorted(words, key=largest_group_size)[:10]
if __name__ == '__main__':
history = []
while True:
guess = best_guess(history)
print('TRY ONE OF:', ', '.join(guess))
guess = input('WHAT WAS YOUR GUESS: ')
result = input('WHAT WAS THE RESULT: ')
if result == 'GGGGG': break
history.append((guess, result))
과거 Wordle을 몇 개 풀어 보고 나서 몇 가지 고친 점이 있다.
best_guess()
는 제일 좋은 추측 하나 대신 상위 10개를 반환한다. words
에 들어 있는 단어들 중 Wordle이 인식하지 않는 단어가 많아서, 그런 경우 그보다 덜 좋은 추측을 사용해야 하기 때문이다.history
에 아무 원소도 없는 경우 best_guess()
는 꽤 느리다. (내 컴퓨터에서는 1분 정도 걸린다.) 다행히 이 경우의 제일 좋은 추측들은 항상 똑같으니까 하드코딩해 뒀다.테스트로 Wordle 200을 풀어 보았다.
$ python wordle.py
TRY ONE OF: OATES, AIRES, ARIES, NEARS, AROSE, SNARE, SANER, REALS, LANES, SADIE
WHAT WAS YOUR GUESS: NEARS
WHAT WAS THE RESULT: .Y.Y.
TRY ONE OF: PRIOR, TIMOR, TUDOR, CHOIR, OILED, RIGOR, OSIER, OLDIE, VIGOR, DOGIE
WHAT WAS YOUR GUESS: PRIOR
WHAT WAS THE RESULT: ..Y.G
TRY ONE OF: VELDT, DOLTS, DELTA, ADULT, DWELT, TILED, TIDAL, DEALT, TILDE, DRAFT
WHAT WAS YOUR GUESS: ADULT
WHAT WAS THE RESULT: ....Y
TRY ONE OF: GAMIN, BIMBO, MIDGE, GAMEY, MAGIC, MOBIL, GIMME, GAMES, MIGHT, LIMBS
WHAT WAS YOUR GUESS: MAGIC
WHAT WAS THE RESULT: ..GY.
TRY ONE OF: TIGER
WHAT WAS YOUR GUESS: TIGER
WHAT WAS THE RESULT: GGGGG
짠. 그래도 프로그램으로 풀고 SNS에 올려 자랑하진 말자.