Commit 9af41332 authored by Korko's avatar Korko
Browse files

Change algorithm for randomization to optimize timing and memory

parent f3ebd0af
<?php
namespace Korko\SecretSanta\Facades;
use Illuminate\Support\Facades\Facade;
class Solver extends Facade
{
/**
* Get the registered name of the component.
*
* @return string
*/
protected static function getFacadeAccessor()
{
return 'solver';
}
}
......@@ -4,8 +4,8 @@ namespace Korko\SecretSanta\Http\Controllers;
use Illuminate\Http\Request;
use Korko\SecretSanta\Http\Requests\RandomFormRequest;
use Korko\SecretSanta\Libs\Resolver;
use Mail;
use Solver;
use Sms;
use Statsd;
......@@ -20,7 +20,7 @@ class RandomFormController extends Controller
{
$participants = $this->getParticipants($request);
$hat = Resolver::resolve($participants);
$hat = Solver::one($participants, array_column($participants, 'exclusions'));
$this->sendMessages($request, $participants, $hat);
......@@ -54,38 +54,39 @@ class RandomFormController extends Controller
protected function sendMessages(Request $request, array $participants, array $hat)
{
foreach ($hat as $santaIdx => $targetName) {
foreach ($hat as $santaIdx => $targetIdx) {
$santa = $participants[$santaIdx];
$target = $participants[$targetIdx];
$this->sendMessage($request, $santa, $targetName);
$this->sendMessage($request, $santa, $target);
}
}
protected function sendMessage(Request $request, array $santa, $targetName)
protected function sendMessage(Request $request, array $santa, array $target)
{
if (!empty($santa['email'])) {
Statsd::gauge('email', '+1');
$this->sendMail($santa, $targetName, $request->input('title'), $request->input('contentMail'));
$this->sendMail($santa, $target, $request->input('title'), $request->input('contentMail'));
}
if (!empty($santa['phone'])) {
Statsd::gauge('phone', '+1');
$this->sendSms($santa, $targetName, $request->input('contentSMS'));
$this->sendSms($santa, $target, $request->input('contentSMS'));
}
}
protected function sendMail($santa, $targetName, $title, $content)
protected function sendMail(array $santa, array $target, $title, $content)
{
$contentMail = str_replace(['{SANTA}', '{TARGET}'], [$santa['name'], $targetName], $content);
$contentMail = str_replace(['{SANTA}', '{TARGET}'], [$santa['name'], $target['name']], $content);
$contentMail .= PHP_EOL.trans('form.mail.post');
Mail::raw($contentMail, function ($m) use ($santa, $title) {
$m->to($santa['email'], $santa['name'])->subject($title);
});
}
protected function sendSms($santa, $targetName, $content)
protected function sendSms(array $santa, array $target, $content)
{
$contentSms = str_replace(['{SANTA}', '{TARGET}'], [$santa['name'], $targetName], $content);
$contentSms = str_replace(['{SANTA}', '{TARGET}'], [$santa['name'], $target['name']], $content);
$contentSms .= PHP_EOL.trans('form.sms.post');
Sms::message($santa['phone'], $contentSms);
}
......
<?php
namespace Korko\SecretSanta\Libs;
use Closure;
use Traversable;
class Combinator
{
public static function all(array $elements, Closure $validator = null) : array
{
$combinations = [];
foreach (self::getGenerator($elements) as $combination) {
$combination = array_combine($elements, $combination);
if (isset($validator) && !self::isCombinationValid($combination, $validator)) {
continue;
}
$combinations[] = $combination;
}
return $combinations;
}
// With php7.1, return should be "?array" to return null in case of error
public static function one(array $elements, Closure $validator = null) : array
{
$rndElements = $elements;
shuffle($rndElements);
foreach (self::getGenerator($rndElements) as $combination) {
$combination = array_combine($elements, $combination);
if (isset($validator) && !self::isCombinationValid($combination, $validator)) {
continue;
}
return $combination;
}
return [];
}
protected static function getGenerator(array $elements) : Traversable
{
if ($elements === []) {
return;
}
foreach (self::getGenerator_internal($elements) as $combination) {
yield $combination;
}
}
private static function getGenerator_internal(array $elements, $combination = []) : Traversable
{
if ($elements === []) {
yield $combination;
} else {
foreach ($elements as $idx => $element) {
// Take the first element
// Add it to the combination
$tmpCombination = $combination;
$tmpCombination[] = $element;
// And remove it from possibilities
$tmpElements = $elements;
unset($tmpElements[$idx]);
yield from self::getGenerator_internal($tmpElements, $tmpCombination);
}
}
}
private static function isCombinationValid(array $combination, Closure $validator) : bool
{
foreach ($combination as $elementA => $elementB) {
if (!$validator($elementA, $elementB)) {
return false;
}
}
return true;
}
}
<?php
namespace Korko\SecretSanta\Libs;
use Exception;
use Generator;
class HatSolver
{
public function one(array $participants, array $exclusions = []) : array
{
$hat = array_keys($participants);
shuffle($hat);
$generator = $this->getGenerator($participants, $exclusions, $hat);
if(!$generator->valid()) {
throw new Exception('Cannot solve');
}
return $generator->current();
}
public function all(array $participants, array $exclusions = []) : array
{
$combinations = [];
$generator = $this->getGenerator($participants, $exclusions, array_keys($participants));
foreach($generator as $combination) {
$combinations[] = $combination;
}
return $combinations;
}
private function getGenerator(array $participants, array $exclusions, array $hat) : Generator
{
return $this->_solve(0, [], $exclusions, $hat);
}
private function _solve($i, array $combination, array $exclusions, array $hat) : Generator
{
$actualExclusions = array_key_exists($i, $exclusions) ? $exclusions[$i] : [];
$actualHat = array_diff($hat, $actualExclusions, [$i]);
if($actualHat !== []) {
foreach($actualHat as $possibility) {
$possibilityCombination = $combination + [$i => $possibility];
$possibilityHat = array_diff($hat, [$possibility]);
if($possibilityHat === []) {
yield $possibilityCombination;
} else {
yield from $this->_solve($i + 1, $possibilityCombination, $exclusions, $possibilityHat);
}
}
} else {
return;
}
}
}
<?php
namespace Korko\SecretSanta\Libs;
use Exception;
class Resolver
{
public static function resolve(array $participants) : array
{
$combination = Combinator::one(array_keys($participants), function ($santa, $target) use ($participants) {
return $santa !== $target && !in_array($target, $participants[$santa]['exclusions']);
});
if ($combination === []) {
throw new Exception('Cannot resolve '.json_encode($participants));
}
foreach ($combination as $idx => $idx2) {
$combination[$idx] = $participants[$idx2]['name'];
}
return $combination;
}
}
......@@ -3,6 +3,7 @@
namespace Korko\SecretSanta\Providers;
use Illuminate\Support\ServiceProvider;
use Korko\SecretSanta\Libs\HatSolver as LibHatSolver;
use Korko\SecretSanta\Libs\SmsTools as LibSmsTools;
use SmsTools;
use Validator;
......@@ -35,5 +36,8 @@ class AppServiceProvider extends ServiceProvider
$this->app->bind('smstools', function ($app) {
return new LibSmsTools();
});
$this->app->bind('solver', function ($app) {
return new LibHatSolver();
});
}
}
......@@ -206,6 +206,7 @@ return [
'Route' => Illuminate\Support\Facades\Route::class,
'Schema' => Illuminate\Support\Facades\Schema::class,
'Session' => Illuminate\Support\Facades\Session::class,
'Solver' => Korko\SecretSanta\Facades\Solver::class,
'Sms' => Aloha\Twilio\Support\Laravel\Facade::class,
'SmsTools' => Korko\SecretSanta\Facades\SmsTools::class,
'Statsd' => League\StatsD\Laravel5\Facade\StatsdFacade::class,
......
......@@ -48,7 +48,7 @@ return [
'sqlite' => [
'driver' => 'sqlite',
'database' => ':memory:',
'database' => storage_path('databases/production.sqlite'),
'prefix' => '',
],
......
<?php
use Korko\SecretSanta\Libs\Resolver;
class ResolverTest extends TestCase
{
public function testDuoResolve()
{
$this->assertEquals([0 => 'B', 1 => 'A'], Resolver::resolve([
0 => ['name' => 'A', 'exclusions' => []],
1 => ['name' => 'B', 'exclusions' => []],
]));
$this->assertEquals([0 => 'A', 1 => 'B'], Resolver::resolve([
0 => ['name' => 'B', 'exclusions' => []],
1 => ['name' => 'A', 'exclusions' => []],
]));
}
public function testTrioResolve()
{
$this->assertEquals([0 => 'B', 1 => 'C', 2 => 'A'], Resolver::resolve([
0 => ['name' => 'A', 'exclusions' => []],
1 => ['name' => 'B', 'exclusions' => ['0']],
2 => ['name' => 'C', 'exclusions' => ['1']],
]));
$this->assertEquals([0 => 'C', 1 => 'A', 2 => 'B'], Resolver::resolve([
0 => ['name' => 'A', 'exclusions' => ['1']],
1 => ['name' => 'B', 'exclusions' => ['2']],
2 => ['name' => 'C', 'exclusions' => []],
]));
}
/**
* @expectedException Exception
* @expectedExceptionMessageRegExp #^Cannot resolve#
*/
public function testFailResolve()
{
Resolver::resolve([
0 => ['name' => 'A', 'exclusions' => ['1', '2']],
1 => ['name' => 'B', 'exclusions' => []],
2 => ['name' => 'C', 'exclusions' => []],
]);
}
}
<?php
use Korko\SecretSanta\Libs\Combinator;
class CombinatorTest extends TestCase
class SolverTest extends TestCase
{
public function assertCombination($valid, $test)
public function assertCombination($valid, $test, $participants)
{
$test = array_map(function ($el) {
$test = array_map(function ($el) use ($participants) {
array_walk($el, function (&$idx) use ($participants) {
$idx = $participants[$idx];
});
return implode('', $el);
}, $test);
$this->assertEquals(sort($valid), sort($test));
}
public function testNoFilter()
{
$this->assertCombination([
'AB', 'BA',
], Combinator::all(['A', 'B']));
}
public function testNoExclusion()
{
$this->assertCombination([
'BA',
], Combinator::all(['A', 'B'], function ($a, $b) {
return $a !== $b;
}));
], Solver::all(['A', 'B']), ['A', 'B']);
$this->assertCombination([
'BCA', 'CAB',
], Combinator::all(['A', 'B', 'C'], function ($a, $b) {
return $a !== $b;
}));
], Solver::all(['A', 'B', 'C']), ['A', 'B', 'C']);
$this->assertCombination([
'BCDA', 'BDAC', 'BADC',
'CDAB', 'CADB', 'CDBA',
'DABC', 'DCBA', 'DCAB',
], Combinator::all(['A', 'B', 'C', 'D'], function ($a, $b) {
return $a !== $b;
}));
], Solver::all(['A', 'B', 'C', 'D']), ['A', 'B', 'C', 'D']);
}
public function testSimpleExclusion()
{
$exclusions = ['A' => ['C']];
// A => C
$this->assertCombination([
'BCA',
], Combinator::all(['A', 'B', 'C'], function ($elementA, $elementB) use ($exclusions) {
return $elementA !== $elementB && (!isset($exclusions[$elementA]) || !in_array($elementB, $exclusions[$elementA]));
}));
], Solver::all(['A', 'B', 'C'], [0 => [2]]), ['A', 'B', 'C']);
}
/*
public function testComplexExclusion()
{
$participants = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', L'];
$exclusions = ['A' => ['B', 'C', 'D']];
$exclusions = [0 => [1, 2, 3]];
$this->assertEquals(array(['A' => 'B', 'B' => 'C', 'C' => 'A']), Combinator::all($participants, function($elementA, $elementB) use($exclusions) {
$this->assertEquals(array(['A' => 'B', 'B' => 'C', 'C' => 'A']), Solver::all($participants, function($elementA, $elementB) use($exclusions) {
return !isset($exclusions[$elementA]) || !in_array($elementB, $exclusions[$elementA]);
}));
}
*/
public function testInvalidExclusion()
public function testImpossibleSolution()
{
$this->assertEquals([], Combinator::all(['A', 'B', 'C'], function ($elementA, $elementB) {
return false;
}));
$this->assertEquals([], Solver::all(['A', 'B', 'C'], [0 => [1, 2]]), ['A', 'B', 'C']);
}
public function testImpossibleSolution()
public function testOne()
{
$exclusions = ['A' => ['B', 'C']];
srand(1);
$this->assertEquals([0 => 1, 1 => 2, 2 => 0], Solver::one(['A', 'B', 'C']));
$this->assertEquals([], Combinator::all(['A', 'B', 'C'], function ($elementA, $elementB) use ($exclusions) {
return $elementA !== $elementB && (!isset($exclusions[$elementA]) || !in_array($elementB, $exclusions[$elementA]));
}));
srand(123);
$this->assertEquals([0 => 2, 1 => 0, 2 => 1], Solver::one(['A', 'B', 'C']));
}
public function testMass()
{
// 702 characters from 'A' to 'ZZ'
$participants = [];
for ($n = 'A'; $n !== 'AAA'; $n++) {
$participants[] = $n;
}
Solver::one($participants);
}
}
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment