mendel 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +18 -0
- data/.rspec +2 -0
- data/.ruby-version +1 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +119 -0
- data/Rakefile +52 -0
- data/TODO.md +15 -0
- data/benchmark/addition_combiner.rb +6 -0
- data/benchmark/benchmarker.rb +58 -0
- data/benchmark/graph.rb +33 -0
- data/benchmark/run_six_lists.rb +17 -0
- data/benchmark/run_two_lists.rb +14 -0
- data/benchmark/simple.rb +34 -0
- data/lib/mendel.rb +5 -0
- data/lib/mendel/combiner.rb +174 -0
- data/lib/mendel/min_priority_queue.rb +48 -0
- data/lib/mendel/observable_combiner.rb +24 -0
- data/lib/mendel/version.rb +3 -0
- data/lib/mendel/visualizers/ascii.rb +54 -0
- data/lib/mendel/visualizers/base.rb +41 -0
- data/mendel.gemspec +28 -0
- data/spec/fixtures/example_input.rb +13 -0
- data/spec/fixtures/example_output/different_lengths.rb +303 -0
- data/spec/fixtures/example_output/inc_integers_w_inc_decimals.rb +10003 -0
- data/spec/fixtures/example_output/inc_integers_w_repeats.rb +10003 -0
- data/spec/fixtures/example_output/inc_integers_w_repeats_and_skips.rb +10003 -0
- data/spec/fixtures/example_output/inc_integers_w_skips.rb +10003 -0
- data/spec/mendel/combiner_spec.rb +256 -0
- data/spec/mendel/min_priority_queue_spec.rb +70 -0
- data/spec/mendel/observable_combiner_spec.rb +42 -0
- data/spec/spec_helper.rb +42 -0
- data/spec/support/foosball_team.rb +24 -0
- data/visualizer_spec/ascii_spec.rb +119 -0
- data/visualizer_spec/base_spec.rb +74 -0
- metadata +175 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 773c767d9054373c84771c9ddc4d8d0a0b83b823
|
4
|
+
data.tar.gz: a56609df4cc57d0f978206562d6962dd3a673ee2
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 6278978743f8c42ac2a3a35bff2dc2e6de520ccd9786e484d7b168b2b0243a30bfbedc75083b490181361f922b7b2705d5108dc2dbef11445f1d8919574256d1
|
7
|
+
data.tar.gz: 8c2f2e5514b45ce1f646d3785b0d8aae0dc62d17d4386b35216d32b568a63227b6c7212f86710bc4c435dba3b7abb2ab9e004fd41ccd5507546a9cbdead7e722
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.ruby-version
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
ruby-2.3.0
|
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2014 Nathan Long
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,119 @@
|
|
1
|
+
# Mendel
|
2
|
+
|
3
|
+
Mendel breeds the best combinations of sorted lists that you provide.
|
4
|
+
|
5
|
+
For example, suppose you have 100 shirts, 200 pairs of pants, and 50 hats, ordered by price. How could you find the 50 cheapest outfits?
|
6
|
+
|
7
|
+
A brute force approach would build all 1 million possibilities (100 * 200 * 50), sort by price, and take the best 50. An ideal solution would build the best 50 and stop.
|
8
|
+
|
9
|
+
Mendel gets much closer to the ideal by incrementally building candidates for the "next best" combination and using a priority queue to pull the best one at any given moment.
|
10
|
+
|
11
|
+
## How it Works
|
12
|
+
|
13
|
+
Mendel is easiest to explain for two lists. In that case, we can think of the combinations as a grid, where the X value is from the first list and the Y value is from the second. Inside the grid, we can represent combinations as the sum of the coordinate values.
|
14
|
+
|
15
|
+
**The lists must be sorted by score**. This means that the sums will increase (or remain constant) along one or both axes.
|
16
|
+
|
17
|
+
For example, imagine that these grids are landscapes, and the scores in the middle are elevations. **Mendel chooses combinations like a tide, rising from the bottom left.**
|
18
|
+
|
19
|
+
+---+ +---+ +---+ +---+
|
20
|
+
1|555| 1|567| 3|777| 3|789|
|
21
|
+
1|555| 1|567| 2|666| 2|678|
|
22
|
+
1|555| 1|567| 1|555| 1|567|
|
23
|
+
+---+ +---+ +---+ +---+
|
24
|
+
444 456 444 456
|
25
|
+
|
26
|
+
In every case, we are guaranteed that the bottom left corner - the best item from list Y combined with the best item from list X - has the lowest elevation. Beyond that, the next best combination could be at `0,1` or `1,0`; we don't know. All we can do is check them both and choose the best one. "Check them both" means producing a score, and to "choose the best one", Mendel uses a [priority queue](https://en.wikipedia.org/wiki/Priority_queue).
|
27
|
+
|
28
|
+
If we find that we've chosen `0,1`, before we return it, we add `1,1` and `0,2` to the queue. We don't know yet whether either of them is better than `1,0`, but next time we need a value, the priority queue will decide. So the water line continues to move up and to the right. Any coordinate "under water" has been returned, any coordinate above the water line has not yet been scored, and any coordinate the water line is just touching is a combination that's currently in the priority queue,
|
29
|
+
|
30
|
+
Run `rake visualize` to see this process in action.
|
31
|
+
|
32
|
+
Mendel does the same process for combinations of 3 or more lists, too. Imagining a 6-dimensional graph is beyond the author's cognitive abilities, but in principle, it's the same.
|
33
|
+
|
34
|
+
## Usage
|
35
|
+
|
36
|
+
Create a combiner class that knows how to score combinations of your items. Then provide lists of items, sorted in ascending value.
|
37
|
+
|
38
|
+
For example:
|
39
|
+
|
40
|
+
```ruby
|
41
|
+
# Simple lists of numbers. Any combination of these
|
42
|
+
# can be scored by adding them together
|
43
|
+
list1 = (1..100).to_a
|
44
|
+
list2 = (1.0..100.0).to_a
|
45
|
+
|
46
|
+
class NumericCombiner
|
47
|
+
include Mendel::Combiner
|
48
|
+
# Scores a combination from the two lists by adding them
|
49
|
+
def score_combination(numbers)
|
50
|
+
numbers.reduce(0) { |sum, number| sum += number }
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
nc = NumericCombiner.new(list1, list2)
|
55
|
+
nc.take(50) # The 50 best combinations
|
56
|
+
```
|
57
|
+
|
58
|
+
Mendel will return two-item arrays of `[combination, score]`.
|
59
|
+
|
60
|
+
A combination of items is, by default, an array with one item from each list. However, if you like, you may specify how to build combinations of your items.
|
61
|
+
|
62
|
+
```ruby
|
63
|
+
defense_players = [{name: 'Jimmy', age: 10}, {name: 'Susan', age: 12}]
|
64
|
+
offense_players = [{name: 'Roger', age: 8}, {name: 'Carla', age: 14}]
|
65
|
+
|
66
|
+
class FoosballTeam
|
67
|
+
attr_accessor :players
|
68
|
+
|
69
|
+
def initialize(*players)
|
70
|
+
self.players = players
|
71
|
+
end
|
72
|
+
|
73
|
+
def average_age
|
74
|
+
players.reduce(0){ |total, player|
|
75
|
+
total += player.fetch(:age)
|
76
|
+
} / 2.0
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
class TeamBuilder
|
81
|
+
include Mendel::Combiner
|
82
|
+
|
83
|
+
def build_combination(players)
|
84
|
+
FoosballTeam.new(*players)
|
85
|
+
end
|
86
|
+
|
87
|
+
def score_combination(team)
|
88
|
+
team.average_age
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
pc = TeamBuilder.new(defense_players, offense_players)
|
93
|
+
pc.take(2) # The youngest teams
|
94
|
+
```
|
95
|
+
|
96
|
+
If you need to apply other criteria besides the score, use lazy enumeration and chain other calls:
|
97
|
+
|
98
|
+
```ruby
|
99
|
+
pc.each.lazy.reject { |team, score| team.contains_siblings? }.take(50).to_a
|
100
|
+
```
|
101
|
+
|
102
|
+
## Serialization and deserialization
|
103
|
+
|
104
|
+
`Mendel::Combiner` provides the instance methods `#dump` and `#dump_json` and the class methods `.load` and `.load_json`. This allows you to pause enumeration, save the data, and resume enumerating some time later.
|
105
|
+
|
106
|
+
## Caveats
|
107
|
+
|
108
|
+
1. **Single Enumeration**. For memory's sake, Mendel **does not keep** combinations it has returned to you. Combinations are built and flushed as you enumerate, so if you enumerate twice, there will be no data the second time; you will have to build a new combiner. If you need to keep the combinations, it is up to you to do so.
|
109
|
+
2. **Memory**. Producing ALL combinations of your lists in inherently expensive. Mendel shines at producing the N best. It will allow you to enumerate all of the combinations, but the more there are, the more memory it will need to queue them up. If you want the top 10,000 combinations, you'll probably be fine. If you want the top 10 billion, I hope you have lots of RAM.
|
110
|
+
|
111
|
+
## Installation
|
112
|
+
|
113
|
+
In Bundler:
|
114
|
+
|
115
|
+
gem 'mendel', git: (this repo address)
|
116
|
+
|
117
|
+
## Naming
|
118
|
+
|
119
|
+
Mendel is named for [Gregor Mendel](https://en.wikipedia.org/wiki/Gregor_Mendel), "the father of modern genetics", a scientist and monk who discovered patterns of inheritance while breeding pea plants. The Mendel gem helps you breed the best possible hybrids of your data.
|
data/Rakefile
ADDED
@@ -0,0 +1,52 @@
|
|
1
|
+
require "bundler/gem_tasks"
|
2
|
+
require 'mendel'
|
3
|
+
require_relative "benchmark/addition_combiner"
|
4
|
+
require 'rspec/core/rake_task'
|
5
|
+
Bundler.setup
|
6
|
+
|
7
|
+
RSpec::Core::RakeTask.new(:spec)
|
8
|
+
|
9
|
+
task "default" => "spec"
|
10
|
+
|
11
|
+
desc "Open IRB to experiment with Mendel::Combiner"
|
12
|
+
task :console do
|
13
|
+
require 'irb'
|
14
|
+
require 'irb/completion'
|
15
|
+
require_relative 'lib/mendel'
|
16
|
+
ARGV.clear
|
17
|
+
IRB.start
|
18
|
+
end
|
19
|
+
|
20
|
+
desc "See a visualization of the Mendel::Combiner algorithm"
|
21
|
+
task :visualize do
|
22
|
+
require 'irb'
|
23
|
+
require "mendel/observable_combiner"
|
24
|
+
require "mendel/visualizers/ascii"
|
25
|
+
class ConsoleCombiner < Mendel::ObservableCombiner
|
26
|
+
def score_combination(items)
|
27
|
+
items.reduce(0) { |sum, item| sum += item }
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def clear_screen
|
32
|
+
system('clear') or system('cls')
|
33
|
+
end
|
34
|
+
|
35
|
+
def show(limit = nil)
|
36
|
+
list1 = 10.times.map { rand(100) }.sort
|
37
|
+
list2 = 10.times.map { rand(1.0...100.0) }.sort
|
38
|
+
combiner = ConsoleCombiner.new(list1, list2)
|
39
|
+
visualizer = Mendel::Visualizers::ASCII.new(combiner)
|
40
|
+
combiner.each_with_index do |combo, i|
|
41
|
+
break if limit.kind_of?(Numeric) && i > limit
|
42
|
+
clear_screen
|
43
|
+
puts visualizer.output
|
44
|
+
sleep(0.5)
|
45
|
+
end; nil
|
46
|
+
end
|
47
|
+
ARGV.clear
|
48
|
+
clear_screen
|
49
|
+
puts "Mendel works like rising water, finding the lowest points"
|
50
|
+
puts "Type 'show()'. Optionally, pass a max number of frames"
|
51
|
+
IRB.start
|
52
|
+
end
|
data/TODO.md
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
# TODO
|
2
|
+
|
3
|
+
- Pretty documentation
|
4
|
+
|
5
|
+
# Probably Not TODO
|
6
|
+
|
7
|
+
## Memory Optimization
|
8
|
+
|
9
|
+
We currently track a set of all seen coordinates. As we build combinations, the size of the set approaches the total number of possible combinations. Although each item in the set is very small (an array of fixnums), the number can grow large.
|
10
|
+
|
11
|
+
The purpose of the set is to keep from queuing the same coordinates repeatedly (eg, [1,1] could be queued as a child of [1,0] and again as a child of [0,1]). We could save memory by not remembering every coordinate we've seen and rejecting subsequent attempts to score that spot; rather, for this purpose it's probably enough to have the priority queue reject duplicates of what it currently has in it. This would make the set an order of magnitude smaller: instead of having every coordinate in a grid (2D), it would have only the advancing edge (1D), or instead of every coordinate in a cube, only the advancing surface.
|
12
|
+
|
13
|
+
This only works if we can assume that a child will never be returned before its parent; eg, [1,1] won't be returned before [0,1]; if it were, when [0,1] got returned, [1,1] would be queued again. That assumption, in turn, is only true if we don't have duplicate scores, which we very well might. In that case, if [0,1] and [1,1] are both scored 10, we can make no guarantees about which will be returned first. A workaround is to give to the priority queue a score consisting of the "normal" score PLUS the coordinates; eg, [10, [1,1]]. This guarantees that children have higher scores than parents.
|
14
|
+
|
15
|
+
A test indicated that this does save memory, but it seems not to be worth the trouble. It makes the code more complicated and, in an upper bound use case for me, consisting of 10 lists of 200 items each and pulling (I think) 40k results, it saved (I think) something like 100MB. So I scrapped it. I record this here only because someone may have a use case where it matters, and an order of magnitude in memory use may be important for them. So: free idea.
|
@@ -0,0 +1,58 @@
|
|
1
|
+
"#{File.expand_path('..',File.dirname(__FILE__))}/lib".tap {|lib_dir|
|
2
|
+
$LOAD_PATH << lib_dir unless $LOAD_PATH.include?(lib_dir)
|
3
|
+
}
|
4
|
+
|
5
|
+
require 'mendel'
|
6
|
+
require 'time'
|
7
|
+
require 'benchmark'
|
8
|
+
require 'csv'
|
9
|
+
|
10
|
+
class Mendel::Benchmarker
|
11
|
+
attr_accessor :combiner, :chunk_size
|
12
|
+
def initialize(combiner, chunk_size)
|
13
|
+
self.combiner = combiner
|
14
|
+
self.chunk_size = chunk_size
|
15
|
+
end
|
16
|
+
|
17
|
+
# Look! A big ol' procedural script shoved into a method!
|
18
|
+
def go!
|
19
|
+
column_names = %i[cstime cutime real stime total utime]
|
20
|
+
|
21
|
+
puts "Benchmarking..."
|
22
|
+
stats = []
|
23
|
+
$stdout = File.open(File::NULL, 'w')
|
24
|
+
Benchmark.bm do |benchmark|
|
25
|
+
done = false
|
26
|
+
until done do
|
27
|
+
# Ensure GC doesn't run during benchmarking
|
28
|
+
GC.disable
|
29
|
+
bm = benchmark.report do
|
30
|
+
chunk = combiner.take(chunk_size)
|
31
|
+
done = true if chunk.empty?
|
32
|
+
end
|
33
|
+
data_point = {queue_length: combiner.queue_length}
|
34
|
+
column_names.map {|colname| data_point[colname] = bm.send(colname) }
|
35
|
+
stats << data_point
|
36
|
+
GC.enable
|
37
|
+
end
|
38
|
+
end
|
39
|
+
$stdout = STDOUT
|
40
|
+
|
41
|
+
puts "Writing performance data into 'benchmark/data'"
|
42
|
+
Dir.chdir('benchmark') do
|
43
|
+
Dir.mkdir('data') unless Dir.exist?('data')
|
44
|
+
Dir.chdir('data') do
|
45
|
+
lengths = combiner.lists.map {|l| l.length.to_s}.join('x')
|
46
|
+
filename = "#{lengths}-#{chunk_size}_each"
|
47
|
+
CSV.open("#{filename}.csv", "wb") do |csv|
|
48
|
+
csv << stats.first.keys
|
49
|
+
stats.each do |entry|
|
50
|
+
csv << entry.values
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
puts "Done!"
|
56
|
+
|
57
|
+
end
|
58
|
+
end
|
data/benchmark/graph.rb
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
data_dir = File.expand_path('data', File.dirname(__FILE__))
|
2
|
+
unless Dir.exist?(data_dir)
|
3
|
+
puts "No data directory found: #{data_dir}"
|
4
|
+
exit
|
5
|
+
end
|
6
|
+
|
7
|
+
require 'gruff'
|
8
|
+
require 'csv'
|
9
|
+
|
10
|
+
Dir.glob("#{data_dir}/*.csv") do |data_file|
|
11
|
+
queue_lengths = []
|
12
|
+
utimes = []
|
13
|
+
|
14
|
+
CSV.foreach(data_file, headers: true) do |row|
|
15
|
+
utimes << row.fetch('utime').to_f
|
16
|
+
queue_lengths << row.fetch('queue_length').to_i
|
17
|
+
end
|
18
|
+
|
19
|
+
base_image_name = data_file.sub('.csv', '')
|
20
|
+
|
21
|
+
g = Gruff::Line.new
|
22
|
+
g.data(:utimes, utimes)
|
23
|
+
g.title = "Time per .take()"
|
24
|
+
g.write("#{base_image_name}_utimes.png")
|
25
|
+
|
26
|
+
g = Gruff::Line.new
|
27
|
+
g.data(:queue_length, queue_lengths)
|
28
|
+
g.title = "Queue Length per .take()"
|
29
|
+
g.write("#{base_image_name}_queue_lengths.png")
|
30
|
+
|
31
|
+
|
32
|
+
|
33
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
require_relative 'benchmarker'
|
2
|
+
require_relative 'addition_combiner'
|
3
|
+
|
4
|
+
chunk_size = ENV.fetch('CS', 25)
|
5
|
+
puts "Using chunk size #{chunk_size} - set ENV var CS to change"
|
6
|
+
|
7
|
+
lists = 6.times.map {
|
8
|
+
# More than this eats a ton of memory
|
9
|
+
8.times.map { rand(1.0...1_000.0) }.sort
|
10
|
+
}
|
11
|
+
|
12
|
+
# require 'pry'
|
13
|
+
# binding.pry
|
14
|
+
# exit
|
15
|
+
|
16
|
+
benchmarker = Mendel::Benchmarker.new(AdditionCombiner.new(*lists), chunk_size)
|
17
|
+
benchmarker.go!
|
@@ -0,0 +1,14 @@
|
|
1
|
+
require_relative 'benchmarker'
|
2
|
+
require_relative 'addition_combiner'
|
3
|
+
|
4
|
+
list1_length = ENV.fetch('L1', 100)
|
5
|
+
list2_length = ENV.fetch('L2', 200)
|
6
|
+
chunk_size = ENV.fetch('CS', 10)
|
7
|
+
puts "Using list lengths #{list1_length} and #{list2_length} and chunk size #{chunk_size}"
|
8
|
+
puts "Set ENV vars L1, L2, and CS to change"
|
9
|
+
|
10
|
+
list1 = list1_length.times.map { rand(1_000_000) }.sort
|
11
|
+
list2 = list2_length.times.map { rand(1.0...1_000_000) }.sort
|
12
|
+
|
13
|
+
benchmarker = Mendel::Benchmarker.new(AdditionCombiner.new(list1, list2), chunk_size)
|
14
|
+
benchmarker.go!
|
data/benchmark/simple.rb
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
"#{File.expand_path('..',File.dirname(__FILE__))}/lib".tap {|lib_dir|
|
2
|
+
$LOAD_PATH << lib_dir unless $LOAD_PATH.include?(lib_dir)
|
3
|
+
}
|
4
|
+
|
5
|
+
require 'mendel'
|
6
|
+
require 'time'
|
7
|
+
require_relative 'addition_combiner'
|
8
|
+
|
9
|
+
list_count = ENV.fetch('LIST_COUNT', 10).to_i
|
10
|
+
list_length = ENV.fetch('LIST_LENGTH', 200).to_i
|
11
|
+
result_count = ENV.fetch('RESULT_COUNT', 10_000).to_i
|
12
|
+
|
13
|
+
puts "Pulling #{result_count} results from #{list_count} lists of #{list_length} each"
|
14
|
+
puts "You may override with ENV vars LIST_COUNT, LIST_LENGTH, RESULT_COUNT"
|
15
|
+
|
16
|
+
if result_count >= list_length**list_count
|
17
|
+
puts "***(You asked for #{result_count} results, but only #{list_length**list_count} are possible...)"
|
18
|
+
end
|
19
|
+
|
20
|
+
lists = list_count.times.map {
|
21
|
+
list_length.times.map { rand(1.0...1_000.0) }.sort
|
22
|
+
}
|
23
|
+
|
24
|
+
puts "Look at the starting memory usage - you have 10 seconds"
|
25
|
+
sleep(10)
|
26
|
+
puts "about to do the work"
|
27
|
+
start = Time.now
|
28
|
+
GC.disable
|
29
|
+
bc = AdditionCombiner.new(*lists)
|
30
|
+
bc.take(result_count)
|
31
|
+
fin = Time.now
|
32
|
+
puts "Took #{fin - start} seconds to pull #{result_count} combos"
|
33
|
+
puts "Look at the final memory usage - you have 10 seconds till exit"
|
34
|
+
sleep(10)
|
data/lib/mendel.rb
ADDED
@@ -0,0 +1,174 @@
|
|
1
|
+
require "mendel/version"
|
2
|
+
require "mendel/min_priority_queue"
|
3
|
+
require "observer"
|
4
|
+
require "set"
|
5
|
+
|
6
|
+
module Mendel
|
7
|
+
|
8
|
+
module Combiner
|
9
|
+
include Enumerable
|
10
|
+
|
11
|
+
attr_accessor :lists, :priority_queue
|
12
|
+
|
13
|
+
def self.included(target)
|
14
|
+
target.extend(ClassMethods)
|
15
|
+
end
|
16
|
+
|
17
|
+
def initialize(*lists)
|
18
|
+
raise EmptyList if lists.any?(&:empty?)
|
19
|
+
self.lists = lists
|
20
|
+
self.priority_queue = MinPriorityQueue.new
|
21
|
+
queue_combo_at(lists.map {0} )
|
22
|
+
end
|
23
|
+
|
24
|
+
def each
|
25
|
+
return self.to_enum unless block_given?
|
26
|
+
loop do
|
27
|
+
combo = next_combination
|
28
|
+
break if combo == :none
|
29
|
+
yield combo
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def dump
|
34
|
+
{INPUT => lists, SEEN => seen_set.to_a, QUEUED => priority_queue.dump }
|
35
|
+
end
|
36
|
+
|
37
|
+
def dump_json
|
38
|
+
JSON.dump(dump)
|
39
|
+
end
|
40
|
+
|
41
|
+
def queue_length
|
42
|
+
priority_queue.length
|
43
|
+
end
|
44
|
+
|
45
|
+
def score_combination(items)
|
46
|
+
raise NotImplementedError,
|
47
|
+
<<-MESSAGE
|
48
|
+
Including class must define. Must take a combination and produce a score.
|
49
|
+
- If you have not defined `build_combination`, `score combination` will receive
|
50
|
+
an array of N items (one from each list)
|
51
|
+
- If you have defined `build_combination`, `score_combination` will receive
|
52
|
+
whatever `build_combination` returns
|
53
|
+
MESSAGE
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
|
58
|
+
def seen_set
|
59
|
+
@seen ||= Set.new
|
60
|
+
end
|
61
|
+
|
62
|
+
def seen_set=(set)
|
63
|
+
@seen = set
|
64
|
+
end
|
65
|
+
|
66
|
+
def next_combination
|
67
|
+
pair = pop_queue
|
68
|
+
return :none if pair.nil?
|
69
|
+
data, score = pair
|
70
|
+
coordinates = data.fetch(COORDINATES)
|
71
|
+
combo = data.fetch(COMBO)
|
72
|
+
queue_children_of(coordinates)
|
73
|
+
[combo, score]
|
74
|
+
end
|
75
|
+
|
76
|
+
def pop_queue
|
77
|
+
priority_queue.pop
|
78
|
+
end
|
79
|
+
|
80
|
+
def queue_children_of(coordinates)
|
81
|
+
children_coordinates = next_steps_from(coordinates)
|
82
|
+
children_coordinates.each {|cc| queue_combo_at(cc) }
|
83
|
+
end
|
84
|
+
|
85
|
+
def queue_combo_at(coordinates)
|
86
|
+
return if seen_set.include?(coordinates)
|
87
|
+
seen_set << coordinates
|
88
|
+
queue_item = queueable_item_for(coordinates)
|
89
|
+
score = queue_item.delete(SCORE)
|
90
|
+
priority_queue.push(queue_item, score)
|
91
|
+
end
|
92
|
+
|
93
|
+
def queueable_item_for(coordinates)
|
94
|
+
raise InvalidCoordinates, coordinates unless valid_for_lists?(coordinates, lists)
|
95
|
+
combo = combo_at(coordinates)
|
96
|
+
score = score_combination(combo)
|
97
|
+
{COMBO => combo, COORDINATES => coordinates, SCORE => score}
|
98
|
+
end
|
99
|
+
|
100
|
+
def combo_at(coordinates)
|
101
|
+
items = lists.each_with_index.map {|list, i| list[coordinates[i]] }
|
102
|
+
build_combination(items)
|
103
|
+
end
|
104
|
+
|
105
|
+
def build_combination(items)
|
106
|
+
items
|
107
|
+
end
|
108
|
+
|
109
|
+
# Increments which are valid for instance's lists
|
110
|
+
def next_steps_from(coordinates)
|
111
|
+
increments_from(coordinates).select { |coords| valid_for_lists?(coords, lists) }
|
112
|
+
end
|
113
|
+
|
114
|
+
# All possible coordinates which are one greater than the given
|
115
|
+
# coords in a single direction.
|
116
|
+
# Eg:
|
117
|
+
# increments_from([0,0])
|
118
|
+
# #=> [[0,1], [1, 0]]
|
119
|
+
# increments_from([10,5,7])
|
120
|
+
# => [[11, 5, 7], [10, 6, 7], [10, 5, 8]]
|
121
|
+
def increments_from(coordinates)
|
122
|
+
coordinates.length.times.map { |i| coordinates.dup.tap { |c| c[i] += 1} }
|
123
|
+
end
|
124
|
+
|
125
|
+
# Do the coordinates represent a valid location given these lists?
|
126
|
+
# Eg:
|
127
|
+
# valid_for_lists?([0,1], [['thundercats', 'voltron'], ['hi', 'ho']])
|
128
|
+
# #=> true - represents ['thundercats', 'ho']
|
129
|
+
# valid_for_lists?([0,2], [['thundercats', 'voltron'], ['hi', 'ho']])
|
130
|
+
# #=> false - first list has an index 0, but second list has no index 2
|
131
|
+
# valid_for_lists?([0,2,0], [['thundercats', 'voltron'], ['hi', 'ho']])
|
132
|
+
# #=> false - there are only two lists
|
133
|
+
def valid_for_lists?(coords, lists)
|
134
|
+
# Must give exactly one index per list
|
135
|
+
return false unless coords.length == lists.length
|
136
|
+
coords.each_with_index.all? { |value, index| valid_index_in?(lists[index], value) }
|
137
|
+
end
|
138
|
+
|
139
|
+
# Eg:
|
140
|
+
# valid_index_in?(['hi', 'ho'], 1) #=> true
|
141
|
+
# valid_index_in?(['hi', 'ho'], 2) #=> false
|
142
|
+
# valid_index_in?(['hi', 'ho'], -2) #=> true
|
143
|
+
# valid_index_in?(['hi', 'ho'], -3) #=> true
|
144
|
+
def valid_index_in?(array, index)
|
145
|
+
index <= (array.length - 1) && index >= (0 - array.length)
|
146
|
+
end
|
147
|
+
|
148
|
+
# To keep from allocating so many strings
|
149
|
+
COMBO = 'combo'.freeze
|
150
|
+
COORDINATES = 'coordinates'.freeze
|
151
|
+
INPUT = 'input'.freeze
|
152
|
+
QUEUED = 'queued'.freeze
|
153
|
+
SCORE = 'score'.freeze
|
154
|
+
SEEN = 'seen'.freeze
|
155
|
+
|
156
|
+
module ClassMethods
|
157
|
+
def load(data)
|
158
|
+
instance = new(*data.fetch(INPUT))
|
159
|
+
instance.instance_eval {
|
160
|
+
self.seen_set = Set.new(data.fetch(SEEN))
|
161
|
+
self.priority_queue = MinPriorityQueue.new.tap {|q| q.load(data.fetch(QUEUED))}
|
162
|
+
}
|
163
|
+
instance
|
164
|
+
end
|
165
|
+
|
166
|
+
def load_json(json)
|
167
|
+
self.load(JSON.parse(json))
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
InvalidCoordinates = Class.new(StandardError)
|
172
|
+
EmptyList = Class.new(StandardError)
|
173
|
+
end
|
174
|
+
end
|