Fast Multi-Threaded C++ Version
I know it's been a while since this thread was active, but I have a cool addition to share: Here's a C++ implementation of the minimax algorithm for Are You The One?, which uses multi-threading to speed up the evaluation of each possible guess.
This version is much faster than the Python version (over 100x faster when original Python version is set to maximum RANDOM_THRESHOLD
and CLEVER_THRESHOLD
). It doesn't use any random guessing, but rather evaluates all permutations and submits as its guess the permutation that eliminates the greatest number of possible solutions (given the worst-case response).
For smaller games, calling "ayto -n" will run the game on all n! possible hidden matchings, and will give you a short summary of the results at the end.
Since it's still intractable to evaluate all 10! possible hidden matchings, if you call "ayto 10", for example, the simulator makes its fixed first three guesses, then runs minimax to choose its guess and assumes that it was given the worst-case evaluation. This leads us down a "worst-case path" to a hidden vector that is presumably in the class of vectors that takes the algorithm a maximum number of guesses to identify. This conjecture has not been tested.
Up to n = 9, there hasn't been a simulation that has taken more than n turns to solve.
To test this yourself, an example compilation would be the following:
g++ -std=c++11 -lpthread -o ayto ayto.cpp
Here's a small example with output:
$ ./ayto -4
Found (0, 1, 2, 3) in 2 guesses.
Found (0, 1, 3, 2) in 3 guesses.
Found (0, 2, 1, 3) in 2 guesses.
Found (0, 2, 3, 1) in 3 guesses.
Found (0, 3, 1, 2) in 2 guesses.
Found (0, 3, 2, 1) in 2 guesses.
Found (1, 0, 2, 3) in 1 guesses.
Found (1, 0, 3, 2) in 3 guesses.
Found (1, 2, 0, 3) in 3 guesses.
Found (1, 2, 3, 0) in 3 guesses.
Found (1, 3, 0, 2) in 3 guesses.
Found (1, 3, 2, 0) in 3 guesses.
Found (2, 0, 1, 3) in 3 guesses.
Found (2, 0, 3, 1) in 3 guesses.
Found (2, 1, 0, 3) in 3 guesses.
Found (2, 1, 3, 0) in 3 guesses.
Found (2, 3, 0, 1) in 3 guesses.
Found (2, 3, 1, 0) in 3 guesses.
Found (3, 0, 1, 2) in 3 guesses.
Found (3, 0, 2, 1) in 3 guesses.
Found (3, 1, 0, 2) in 3 guesses.
Found (3, 1, 2, 0) in 3 guesses.
Found (3, 2, 0, 1) in 3 guesses.
Found (3, 2, 1, 0) in 3 guesses.
***** SUMMARY *****
Avg. Turns: 2.75
Worst Hidden Vector: (0, 1, 3, 2) in 3 turns.
Code
/* Multithreaded Mini-max Solver for MTV's Are You The One? */
#include <iostream>
#include <cstdlib>
#include <ctime>
#include <cassert>
#include <algorithm>
#include <numeric>
#include <string>
#include <vector>
#include <map>
#include <thread>
#include <cmath>
#define TEN_FACT (3628800)
#define NUM_CHUNKS (8)
using std::cout;
using std::cin;
using std::endl;
using std::vector;
using std::string;
using std::map;
using std::pair;
using std::find;
using std::abs;
using std::atoi;
using std::next_permutation;
using std::max_element;
using std::accumulate;
using std::reverse;
using std::thread;
struct args {
vector<string> *perms;
vector<string> *chunk;
pair<string, int> *cd;
int thread_id;
};
void simulate_game(const string &hidden, map<string, int> &turns_taken,
bool running_all);
bool picmp(const pair<string, int> &p1, const pair<string, int> &p2);
double map_avg(const map<string, int> &mp);
int nrand(int n);
int evaluate(const string &sol, const string &query);
vector<string> remove_perms(vector<string> &perms, int eval, string &query);
pair<string, int> guess_tb(vector<string> &perms, vector<string> &guessed_tb, int turn);
pair<string, int> guess_pm(vector<string> &perms, vector<string> &guessed, int turn);
void make_chunks(vector<string> &orig, vector<vector<string> > &chunks, int n);
string min_candidate(pair<string, int> *candidates, int n);
void get_score(struct args *args);
int wc_response(string &guess, vector<string> &perms);
bool prcmp(pair<int, int> x, pair<int, int> y);
void sequence_print(string s);
struct args **create_args(vector<string> &perms, pair<string, int> *cd, vector<string> &chunk, int thread_id);
vector<string> ORIGPERMS;
int main(int argc, char **argv)
{
int sz;
map<string, int> turns_taken;
const string digits = "0123456789";
bool running_all = false;
if (argc != 2) {
cout << "usage: 'ayto npairs'" << endl;
return 1;
} else {
if ((sz = atoi(argv[1])) < 0) {
sz = -sz;
running_all = true;
}
if (sz < 3 || sz > 10) {
cout << "usage: 'ayto npairs' where 3 <= npairs <= 10" << endl;;
return 1;
}
}
// initialize ORIGPERMS and possible_perms
string range = digits.substr(0, sz);
do {
ORIGPERMS.push_back(range);
} while (next_permutation(range.begin(), range.end()));
if (running_all) {
for (vector<string>::const_iterator it = ORIGPERMS.begin();
it != ORIGPERMS.end(); ++it) {
simulate_game(*it, turns_taken, running_all);
}
cout << "***** SUMMARY *****\n";
cout << "Avg. Turns: " << map_avg(turns_taken) << endl;
pair<string, int> wc = *max_element(turns_taken.begin(),
turns_taken.end(), picmp);
cout << "Worst Hidden Vector: ";
sequence_print(wc.first);
cout << " in " << wc.second << " turns." << endl;
} else {
string hidden = ORIGPERMS[nrand(ORIGPERMS.size())];
simulate_game(hidden, turns_taken, running_all);
}
return 0;
}
// simulate_game: run a single round of AYTO on hidden vector
void simulate_game(const string &hidden, map<string, int> &turns_taken,
bool running_all)
{
vector<string> possible_perms = ORIGPERMS;
pair<string, int> tbguess;
pair<string, int> pmguess;
vector<string> guessed;
vector<string> guessed_tb;
int e;
int sz = hidden.size();
if (!running_all) {
cout << "Running AYTO Simulator on Hidden Vector: ";
sequence_print(hidden);
cout << endl;
}
for (int turn = 1; ; ++turn) {
// stage one: truth booth
if (!running_all) {
cout << "**** Round " << turn << "A ****" << endl;
cout << "Num. Possibilities: " << possible_perms.size() << endl;
}
tbguess = guess_tb(possible_perms, guessed_tb, turn);
if (!running_all) {
cout << "Guess: ";
sequence_print(tbguess.first);
cout << endl;
e = tbguess.second;
cout << "Worst-Case Evaluation: " << e << endl;
} else {
e = evaluate(hidden, tbguess.first);
}
possible_perms = remove_perms(possible_perms, e, tbguess.first);
// stage two: perfect matching
if (!running_all) {
cout << "Round " << turn << "B" << endl;
cout << "Num. Possibilities: " << possible_perms.size() << endl;
}
pmguess = guess_pm(possible_perms, guessed, turn);
if (!running_all) {
cout << "Guess: ";
sequence_print(pmguess.first);
cout << endl;
e = pmguess.second;
cout << "Worst-Case Evaluation: " << e << endl;
} else {
e = evaluate(hidden, pmguess.first);
}
if (e == sz) {
cout << "Found ";
sequence_print(pmguess.first);
cout << " in " << turn << " guesses." << endl;
turns_taken[pmguess.first] = turn;
break;
}
possible_perms = remove_perms(possible_perms, e, pmguess.first);
}
}
// map_avg: returns average int component of a map<string, int> type
double map_avg(const map<string, int> &mp)
{
double sum = 0.0;
for (map<string, int>::const_iterator it = mp.begin();
it != mp.end(); ++it) {
sum += it->second;
}
return sum / mp.size();
}
// picmp: comparison function for pair<string, int> types, via int component
bool picmp(const pair<string, int> &p1, const pair<string, int> &p2)
{
return p1.second < p2.second;
}
// nrand: random integer in range [0, n)
int nrand(int n)
{
srand(time(NULL));
return rand() % n;
}
// evaluate: number of black hits from permutation or truth booth query
int evaluate(const string &sol, const string &query)
{
int hits = 0;
if (sol.size() == query.size()) {
// permutation query
int s = sol.size();
for (int i = 0; i < s; i++) {
if (sol[i] == query[i])
++hits;
}
} else {
// truth booth query
if (sol[atoi(query.substr(0, 1).c_str())] == query[1])
++hits;
}
return hits;
}
// remove_perms: remove solutions that are no longer possible after an eval
vector<string> remove_perms(vector<string> &perms, int eval, string &query)
{
vector<string> new_perms;
for (vector<string>::iterator i = perms.begin(); i != perms.end(); i++) {
if (evaluate(*i, query) == eval) {
new_perms.push_back(*i);
}
}
return new_perms;
}
// guess_tb: guesses best pair (pos, val) to go to the truth booth
pair<string, int> guess_tb(vector<string> &possible_perms,
vector<string> &guessed_tb, int turn)
{
static const string digits = "0123456789";
int n = possible_perms[0].size();
pair<string, int> next_guess;
if (turn == 1) {
next_guess.first = "00";
next_guess.second = 0;
} else if (possible_perms.size() == 1) {
next_guess.first = "0" + possible_perms[0].substr(0, 1);
next_guess.second = 1;
} else {
map<string, double> pair_to_count;
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
pair_to_count[digits.substr(i, 1) + digits.substr(j, 1)] = 0;
}
}
// count up the occurrences of each pair in the possible perms
for (vector<string>::iterator p = possible_perms.begin();
p != possible_perms.end(); p++) {
int len = possible_perms[0].size();
for (int i = 0; i < len; i++) {
pair_to_count[digits.substr(i, 1) + (*p).substr(i, 1)] += 1;
}
}
double best_dist = 1;
int perm_cnt = possible_perms.size();
for (map<string, double>::iterator i = pair_to_count.begin();
i != pair_to_count.end(); i++) {
if (find(guessed_tb.begin(), guessed_tb.end(), i->first)
== guessed_tb.end()) {
// hasn't been guessed yet
if (abs(i->second/perm_cnt - .5) < best_dist) {
next_guess.first = i->first;
best_dist = abs(i->second/perm_cnt - .5);
if (i->second / perm_cnt < 0.5) // occurs in < half perms
next_guess.second = 0;
else // occurs in >= half perms
next_guess.second = 1;
}
}
}
}
guessed_tb.push_back(next_guess.first);
return next_guess;
}
// guess_pm: guess a full permutation using minimax
pair<string, int> guess_pm(vector<string> &possible_perms,
vector<string> &guessed, int turn)
{
static const string digits = "0123456789";
pair<string, int> next_guess;
vector<vector<string> > chunks;
int sz = possible_perms[0].size();
// on first turn, we guess "0, 1, ..., n-1" if truth booth was correct
// or "1, 0, ..., n-1" if truth booth was incorrect
if (turn == 1) {
int fact, i;
for (i = 2, fact = 1; i <= sz; fact *= i++)
;
if (possible_perms.size() == fact) {
next_guess.first = digits.substr(0, sz);
next_guess.second = 1;
} else {
next_guess.first = "10" + digits.substr(2, sz - 2);
next_guess.second = 1;
}
} else if (possible_perms.size() == 1) {
next_guess.first = possible_perms[0];
next_guess.second = possible_perms[0].size();
} else {
// run multi-threaded minimax to get next guess
pair<string, int> candidates[NUM_CHUNKS];
vector<thread> jobs;
make_chunks(ORIGPERMS, chunks, NUM_CHUNKS);
struct args **args = create_args(possible_perms, candidates, chunks[0], 0);
for (int j = 0; j < NUM_CHUNKS; j++) {
args[j]->chunk = &(chunks[j]);
args[j]->thread_id = j;
jobs.push_back(thread(get_score, args[j]));
}
for (int j = 0; j < NUM_CHUNKS; j++) {
jobs[j].join();
}
next_guess.first = min_candidate(candidates, NUM_CHUNKS);
next_guess.second = wc_response(next_guess.first, possible_perms);
for (int j = 0; j < NUM_CHUNKS; j++)
free(args[j]);
free(args);
}
guessed.push_back(next_guess.first);
return next_guess;
}
struct args **create_args(vector<string> &perms, pair<string, int> *cd, vector<string> &chunk, int thread_id)
{
struct args **args = (struct args **) malloc(sizeof(struct args*)*NUM_CHUNKS);
assert(args);
for (int i = 0; i < NUM_CHUNKS; i++) {
args[i] = (struct args *) malloc(sizeof(struct args));
assert(args[i]);
args[i]->perms = &perms;
args[i]->cd = cd;
}
return args;
}
// make_chunks: return pointers to n (nearly) equally sized vectors
// from the original vector
void make_chunks(vector<string> &orig, vector<vector<string> > &chunks, int n)
{
int sz = orig.size();
int chunk_sz = sz / n;
int n_with_extra = sz % n;
vector<string>::iterator b = orig.begin();
vector<string>::iterator e;
for (int i = 0; i < n; i++) {
int m = chunk_sz; // size of this chunk
if (n_with_extra) {
++m;
--n_with_extra;
}
e = b + m;
vector<string> subvec(b, e);
chunks.push_back(subvec);
b = e;
}
}
// min_candidate: string with min int from array of pair<string, ints>
string min_candidate(pair<string, int> *candidates, int n)
{
int i, minsofar;
string minstring;
minstring = candidates[0].first;
minsofar = candidates[0].second;
for (i = 1; i < n; ++i) {
if (candidates[i].second < minsofar) {
minsofar = candidates[i].second;
minstring = candidates[i].first;
}
}
return minstring;
}
// get_score: find the maximum number of remaining solutions over all
// possible responses to the query s
// this version takes a chunk and finds the guess with lowest score
// from that chunk
void get_score(struct args *args)
{
// parse the args struct
vector<string> &chunk = *(args->chunk);
vector<string> &perms = *(args->perms);
pair<string, int> *cd = args->cd;
int thread_id = args->thread_id;
typedef vector<string>::const_iterator vec_iter;
int sz = perms[0].size();
pair<string, int> best_guess;
best_guess.second = perms.size();
int wc_num_remaining;
for (vec_iter s = chunk.begin(); s != chunk.end(); ++s) {
vector<int> matches(sz + 1, 0);
for (vec_iter p = perms.begin(); p != perms.end(); ++p) {
++matches[evaluate(*s, *p)];
}
wc_num_remaining = *max_element(matches.begin(), matches.end());
if (wc_num_remaining < best_guess.second) {
best_guess.first = *s;
best_guess.second = wc_num_remaining;
}
}
cd[thread_id] = best_guess;
return;
}
// wc_response: the response to guess that eliminates the least solutions
int wc_response(string &guess, vector<string> &perms)
{
map<int, int> matches_eval;
for (vector<string>::iterator it = perms.begin(); it!=perms.end(); ++it) {
++matches_eval[evaluate(guess, *it)];
}
return max_element(matches_eval.begin(), matches_eval.end(), prcmp)->first;
}
// prcmp: comparison function for pair<int, int> types in map
bool prcmp(pair<int, int> x, pair<int, int> y)
{
return x.second < y.second;
}
void sequence_print(const string s)
{
for (string::const_iterator i = s.begin(); i != s.end(); i++) {
if (i == s.begin())
cout << "(";
cout << *i;
if (i != s.end() - 1)
cout << ", ";
else
cout << ")";
}
}
3If you want the person who can solve it in the least average guesses to win, why not make that the winning criteria? I can't see any reason this should be a popularity contest when a perfectly valid win condition is staring us in the face. – Geobits – 2014-10-31T01:25:07.647
As far as I can tell the question doesn't have anything to do with optimally playing Mastermind. It asks for a game that allows a user to play it. – feersum – 2014-10-31T01:28:33.013
@Geobits Wasn't sure how people would respond to that. I agree that's a better criteria though. It just might be hard to judge. – dberm22 – 2014-10-31T01:29:16.463
@Feersum I am asking for the optimal strategy. Fewest average number of turns to guess the solution will be the accepted answer. – dberm22 – 2014-10-31T01:31:03.080
1Then pop-contest is not the tag for this. – None – 2014-10-31T02:08:13.897
1@hosch250 Updated criterion for winner – dberm22 – 2014-10-31T02:24:00.957
OK, seems pretty good. – None – 2014-10-31T03:04:09.193
27 upvotes, 2 favorites, and no answers. I knew this was a tough one! – dberm22 – 2014-11-01T19:29:28.623