knapsack_solver 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,47 @@
1
+ require 'knapsack_solver/solving_methods/dynamic_programming'
2
+
3
+ module KnapsackSolver
4
+ # This class implements methods for solving 0/1 knapsack problem using Fully
5
+ # Polynomial Time Approximation Scheme.
6
+ class Fptas
7
+ # Initializes 0/1 knapsack FPTAS solver.
8
+ #
9
+ # @param instance [Instance] Instance of a 0/1 knapsack problem.
10
+ # @param epsilon [Instances] Maximum allowed relative error of the resulting price.
11
+ def initialize(instance, epsilon)
12
+ @instance = instance
13
+ @epsilon = epsilon.to_f
14
+ @orig_prices = @instance.things.map(&:price)
15
+ end
16
+
17
+ # Solve the instance of 0/1 knapsack problem using FPTAS.
18
+ #
19
+ # @return [Hash] resulting price and thing configuration (0 = thing is not in the knapsack, 1 = thing is there)
20
+ def run
21
+ modify_prices_for_epsilon!
22
+ r = DynamicProgramming.new(@instance).run
23
+ p = get_normal_price_from_fptas(r[:config])
24
+ { price: p, config: r[:config] }
25
+ end
26
+
27
+ protected
28
+
29
+ # Modifies prices of the things according to the supplied epsilon constant
30
+ # to achieve max. allowed relative error.
31
+ def modify_prices_for_epsilon!
32
+ m = @instance.things.max_by(&:price).price
33
+ k = (@epsilon * m) / @instance.things.size
34
+ @instance.things.each { |t| t.price = (t.price.to_f / k).floor }
35
+ end
36
+
37
+ # Computes resulting price using original unmodified prices of things.
38
+ #
39
+ # @param presenve [Array] configuration variables vector
40
+ # @return [Integer] total price of things in the knapsack
41
+ def get_normal_price_from_fptas(presence)
42
+ @instance.things.reduce(0) do |price, t|
43
+ price + ((presence[t.index] != 0 ? @orig_prices[t.index] : 0))
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,56 @@
1
+ module KnapsackSolver
2
+ # This class implements methods for solving 0/1 knapsack problem using
3
+ # simple heuristic by price to weight ratio. Things with the best price to
4
+ # weight ratio are selected first.
5
+ class HeuristicPriceToWeight
6
+ # Initializes instance of 0/1 knapsack problem solver using simple
7
+ # heuristic by price to weight ratio.
8
+ #
9
+ # @param instance [Instance] 0/1 knapsack problem instance
10
+ def initialize(instance)
11
+ @instance = instance
12
+ @config = Array.new(instance.things.size) { 0 }
13
+ @sorted_things = instance.things.sort do |a, b|
14
+ (b.price.to_f / b.weight) <=> (a.price.to_f / a.weight)
15
+ end
16
+ end
17
+
18
+ # Solve the instance of 0/1 knapsack problem.
19
+ #
20
+ # @return [Hash] resulting price and thing configuration (0 = thing is not in the knapsack, 1 = thing is there)
21
+ def run
22
+ solve
23
+ { price: @best_price, config: @best_config }
24
+ end
25
+
26
+ protected
27
+
28
+ # Solve the instance of 0/1 knapsack problem.
29
+ def solve
30
+ @sorted_things.each do |thing|
31
+ break if (config_weight + thing.weight) > @instance.weight_capacity
32
+ @config[thing.index] = 1
33
+ end
34
+ @best_price = config_price
35
+ @best_config = @config.dup
36
+ end
37
+
38
+ # Gets total weight of things present in the knapsack.
39
+ #
40
+ # @return [Integer] total weight
41
+ def config_weight
42
+ @config.each_with_index.reduce(0) do |weight, (presence, index)|
43
+ weight + presence * @instance.things[index].weight
44
+ end
45
+ end
46
+
47
+ # Gets total price of things present in the knapsack.
48
+ #
49
+ # @return [Integer] total price
50
+ def config_price
51
+ @config.each_with_index.reduce(0) do |price, (presence, index)|
52
+ price + presence * @instance.things[index].price
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,5 @@
1
+ # Namespace for classes and modules for 0/1 knapsack problem utility.
2
+ module KnapsackSolver
3
+ # Version of this comman-line utility for solving 0/1 knapsack problem.
4
+ VERSION = '0.1.0'.freeze
5
+ end
@@ -0,0 +1,103 @@
1
+ require 'rspec/expectations'
2
+
3
+ def comment_lines(file_path)
4
+ File.open(file_path, 'r').each_line.select { |l| l.chars.first == '#' }
5
+ end
6
+
7
+ def data_lines(file_path)
8
+ File.open(file_path, 'r').each_line.select { |l| l.chars.first != '#' }
9
+ end
10
+
11
+
12
+ RSpec::Matchers.define :be_a_regular_file do
13
+ match do |actual|
14
+ File.file?(actual)
15
+ end
16
+ end
17
+
18
+ RSpec::Matchers.define :be_an_empty_directory do
19
+ match do |actual|
20
+ Dir.empty?(actual)
21
+ end
22
+ end
23
+
24
+ RSpec::Matchers.define :be_a_valid_results_file do
25
+ match do |actual|
26
+ begin
27
+ comment_lines = comment_lines(actual)
28
+ data_lines = data_lines(actual)
29
+ # It must have 3 lines: 2 comments and >= 1 data line
30
+ return false if comment_lines.size != 2 || data_lines.size < 1
31
+ # Data line must consist of non-negative numbers and array 1 and 0
32
+ data_lines.each do |l|
33
+ l.scan(/\[[^\]]*\]/).first.tr('[]', '').split(',').map { |n| n.to_i }.each do |i|
34
+ return false if i != 0 && i != 1
35
+ end
36
+ l.scan(/[0-9\.]+/).map { |n| n.to_f }.each do |i|
37
+ return false if i < 0
38
+ end
39
+ end
40
+ true
41
+ rescue
42
+ false
43
+ end
44
+ end
45
+ end
46
+
47
+ RSpec::Matchers.define :be_a_equal_to_results_file do |good|
48
+ match do |actual|
49
+ begin
50
+ lines = data_lines(actual)
51
+ good_lines = data_lines(good)
52
+ return false if lines.size != good_lines.size
53
+ lines.each_with_index do |l, i|
54
+ # Check price and configuration
55
+ return false if l.split(']').first != good_lines[i].split(']').first
56
+ # Check relative error
57
+ return false if l.split().last != good_lines[i].split().last
58
+ return false if l.split().size != good_lines[i].split().size
59
+ end
60
+ true
61
+ rescue
62
+ false
63
+ end
64
+ end
65
+ end
66
+
67
+ RSpec::Matchers.define :be_a_equal_to_stats_file do |good|
68
+ match do |actual|
69
+ begin
70
+ lines = data_lines(actual)
71
+ good_lines = data_lines(good)
72
+ return false if lines.size != good_lines.size
73
+ lines.each_with_index do |l, i|
74
+ # Check average price
75
+ return false if l.split().first != good_lines[i].split().first
76
+ # Check average relative error
77
+ return false if l.split().last != good_lines[i].split().last
78
+ return false if l.split().size != good_lines[i].split().size
79
+ end
80
+ true
81
+ rescue
82
+ false
83
+ end
84
+ end
85
+ end
86
+
87
+ RSpec::Matchers.define :be_a_valid_stats_file do
88
+ match do |actual|
89
+ begin
90
+ comment_lines = comment_lines(actual)
91
+ data_lines = data_lines(actual)
92
+ # It must have 3 lines: 2 comments and 1 data line
93
+ return false if comment_lines.size != 2 || data_lines.size != 1
94
+ # Data line must consist of non-negative numbers
95
+ data_lines.first.split.map { |i| i.to_f }.each do |n|
96
+ return false if n < 0
97
+ end
98
+ true
99
+ rescue
100
+ false
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,261 @@
1
+ require 'rspec'
2
+ require 'tmpdir'
3
+ require_relative 'spec_helper'
4
+ require_relative 'knapsack_solver_matchers'
5
+ require_relative '../lib/knapsack_solver/cli.rb'
6
+ require_relative '../lib/knapsack_solver/version.rb'
7
+
8
+ module FileHelper
9
+ def file_list(directory, files)
10
+ files.map { |f| File.join(directory, f) }
11
+ end
12
+ end
13
+
14
+ module ArgumentHelper
15
+ def args(args_string = nil)
16
+ return [] if args_string.nil?
17
+ args_string.split
18
+ end
19
+
20
+ def args_in_dataset_out_file(args_string, tmpdir)
21
+ args(args_string) + ['-o', tmpdir, 'test/datasets/size_4.dataset', 'test/datasets/size_10.dataset']
22
+ end
23
+ end
24
+
25
+ describe KnapsackSolver::CLI do
26
+ include FileHelper
27
+ include ArgumentHelper
28
+
29
+ subject(:cli) { KnapsackSolver::CLI }
30
+
31
+ context 'options' do
32
+ it 'recognizes invalid options' do
33
+ expect { cli.run(args('-a')) }.to raise_error(OptionParser::InvalidOption)
34
+ expect { cli.run(args('-k -h')) }.to raise_error(OptionParser::InvalidOption)
35
+ expect { cli.run(args('-x -b')) }.to raise_error(OptionParser::InvalidOption)
36
+ end
37
+
38
+ it 'detects missing arguments' do
39
+ expect { cli.run(args('-o')) }.to raise_error(OptionParser::MissingArgument)
40
+ expect { cli.run(args('-g')) }.to raise_error(OptionParser::MissingArgument)
41
+ end
42
+
43
+ it '-h has the top priority among valid options' do
44
+ expect { cli.run(args('-b -v -h')) }.to output(/Usage:/).to_stdout
45
+ expect { cli.run(args('-b -h -v')) }.to output(/Usage:/).to_stdout
46
+ expect { cli.run(args('-h -b -v')) }.to output(/Usage:/).to_stdout
47
+ end
48
+
49
+ it '-v has a second from the top priority among valid options' do
50
+ expect { cli.run(args('-v -h')) }.to output(/Usage:/).to_stdout
51
+ expect { cli.run(args('-v -b')) }.to output(/knapsack_solver #{KnapsackSolver::VERSION}/).to_stdout
52
+ end
53
+
54
+ it 'at least one method of solving must be selected' do
55
+ Dir.mktmpdir() do |tmpdir|
56
+ expect { cli.run(args()) }.to raise_error(/At least one method of solving must be requested/)
57
+ expect { cli.run(args_in_dataset_out_file('-b', tmpdir)) }.not_to raise_error
58
+ end
59
+ end
60
+
61
+ it 'FPTAS must have epsilon constant provided' do
62
+ Dir.mktmpdir() do |tmpdir|
63
+ expect { cli.run(args_in_dataset_out_file('-f', tmpdir)) }.to raise_error(/Missing FPTAS epsilon constant/)
64
+ expect { cli.run(args_in_dataset_out_file('-f -e 0.5', tmpdir)) }.not_to raise_error
65
+ end
66
+ end
67
+
68
+ it 'FPTAS epsilon constant must be number from range (0,1)' do
69
+ Dir.mktmpdir() do |tmpdir|
70
+ expect { cli.run(args_in_dataset_out_file('-f -e asdf', tmpdir)) }.to raise_error(/FPTAS epsilon must be number from range \(0,1\)/)
71
+ expect { cli.run(args_in_dataset_out_file('-f -e 0.5x', tmpdir)) }.to raise_error(/FPTAS epsilon must be number from range \(0,1\)/)
72
+ expect { cli.run(args_in_dataset_out_file('-f -e -0.3', tmpdir)) }.to raise_error(/FPTAS epsilon must be number from range \(0,1\)/)
73
+ expect { cli.run(args_in_dataset_out_file('-f -e 0', tmpdir)) }.to raise_error(/FPTAS epsilon must be number from range \(0,1\)/)
74
+ expect { cli.run(args_in_dataset_out_file('-f -e 0.01', tmpdir)) }.not_to raise_error
75
+ expect { cli.run(args_in_dataset_out_file('-f -e 0.99', tmpdir)) }.not_to raise_error
76
+ expect { cli.run(args_in_dataset_out_file('-f -e 1', tmpdir)) }.to raise_error(/FPTAS epsilon must be number from range \(0,1\)/)
77
+ end
78
+ end
79
+
80
+ it 'epsilon constant must not be provided when FPTAS is not selected' do
81
+ Dir.mktmpdir() do |tmpdir|
82
+ expect { cli.run(args_in_dataset_out_file('-b -e 0.5', tmpdir)) }.to raise_error(/epsilon constant must not be provided when FPTAS is not selected/)
83
+ expect { cli.run(args_in_dataset_out_file('-b -f -e 0.5', tmpdir)) }.not_to raise_error
84
+ end
85
+ end
86
+
87
+ end
88
+
89
+ context 'positional arguments' do
90
+ it 'must have at least one dataset provided' do
91
+ expect { cli.run(args('-b')) }.to raise_error(/Missing datset file\(s\)/)
92
+ end
93
+
94
+ it 'dataset path must be a path to a regular file' do
95
+ Dir.mktmpdir do |tmpdir|
96
+ expect { cli.run(args('-b ' + tmpdir)) }.to raise_error(/is not a regular file/)
97
+ end
98
+ end
99
+
100
+ it 'dataset file must exist' do
101
+ Dir.mktmpdir do |tmpdir|
102
+ not_existent_file = File.join(tmpdir, 'size_4.dataset')
103
+ expect { cli.run(args('-b ' + not_existent_file)) }.to raise_error(/does not exists/)
104
+ end
105
+ end
106
+
107
+ it 'dataset file must be readable' do
108
+ Dir.mktmpdir do |tmpdir|
109
+ FileUtils.cp('test/datasets/size_4.dataset', tmpdir)
110
+ not_readable_file = File.join(tmpdir, 'size_4.dataset')
111
+ FileUtils.chmod('a-r', not_readable_file)
112
+ expect { cli.run(args('-b ' + not_readable_file)) }.to raise_error(/is not readable/)
113
+ end
114
+ end
115
+
116
+ it 'dataset file must have correct format' do
117
+ Dir.mktmpdir do |tmpdir|
118
+ invalid_files = %w(invalid_1.dataset
119
+ invalid_2.dataset
120
+ invalid_3.dataset
121
+ invalid_4.dataset
122
+ invalid_5.dataset
123
+ invalid_6.dataset
124
+ invalid_7.dataset
125
+ invalid_8.dataset)
126
+ error_messages = ['missing ID',
127
+ 'first line does not contain ID',
128
+ 'ID is negative',
129
+ 'ID is not an integer',
130
+ 'missing knapsack capacity',
131
+ 'missing pairs \(price, weight\)',
132
+ 'instance desctiption contains negative number',
133
+ 'instance desctiption does not contain only integers']
134
+ file_list('test/invalid_datasets', invalid_files).each_with_index do |f, i|
135
+ expect { cli.run(args('-d ' + f)) }.to raise_error(/#{error_messages[i]}/)
136
+ end
137
+ end
138
+ end
139
+ end
140
+
141
+ context 'output of results' do
142
+ it 'directory for the output logs must exist' do
143
+ Dir.mktmpdir do |tmpdir|
144
+ not_existent_file = File.join(tmpdir, 'size_4.dataset')
145
+ expect { cli.run(args('-b -o ' + not_existent_file)) }.to raise_error(/does not exists/)
146
+ end
147
+ end
148
+
149
+ it 'path to a directory for the output logs must point to a directory' do
150
+ Dir.mktmpdir do |tmpdir|
151
+ FileUtils.cp('test/datasets/size_4.dataset', tmpdir)
152
+ file = File.join(tmpdir, 'size_4.dataset')
153
+ expect { cli.run(args('-b -o ' + file)) }.to raise_error(/is not a directory/)
154
+ end
155
+ end
156
+
157
+ it 'directory for the output logs must be writable' do
158
+ Dir.mktmpdir do |tmpdir|
159
+ not_writable_dir = File.join(tmpdir, 'dir')
160
+ Dir.mkdir(not_writable_dir)
161
+ FileUtils.chmod('a-w', not_writable_dir)
162
+ expect { cli.run(args('-b -o ' + not_writable_dir)) }.to raise_error(/is not writable/)
163
+ end
164
+ end
165
+
166
+ it 'directory for the graph files must exist' do
167
+ Dir.mktmpdir do |tmpdir|
168
+ not_existent_file = File.join(tmpdir, 'size_4.dataset')
169
+ expect { cli.run(args('-b -g ' + not_existent_file)) }.to raise_error(/does not exists/)
170
+ end
171
+ end
172
+
173
+ it 'path to a directory for the graph files must point to a directory' do
174
+ Dir.mktmpdir do |tmpdir|
175
+ FileUtils.cp('test/datasets/size_4.dataset', tmpdir)
176
+ file = File.join(tmpdir, 'size_4.dataset')
177
+ expect { cli.run(args('-b -g ' + file)) }.to raise_error(/is not a directory/)
178
+ end
179
+ end
180
+
181
+ it 'directory for the graph files must be writable' do
182
+ Dir.mktmpdir do |tmpdir|
183
+ not_writable_dir = File.join(tmpdir, 'dir')
184
+ Dir.mkdir(not_writable_dir)
185
+ FileUtils.chmod('a-w', not_writable_dir)
186
+ expect { cli.run(args('-b -g ' + not_writable_dir)) }.to raise_error(/is not writable/)
187
+ end
188
+ end
189
+
190
+ it 'writes graph files' do
191
+ Dir.mktmpdir do |tmpdir|
192
+ png_files = %w(avg_price.png avg_cpu_time.png avg_wall_clock_time.png avg_relative_error.png)
193
+ gnuplot_files = %w(avg_price.gnuplot avg_cpu_time.gnuplot avg_wall_clock_time.gnuplot avg_relative_error.gnuplot)
194
+ files = png_files + gnuplot_files
195
+ cli.run(args_in_dataset_out_file('-b -g ' + tmpdir, tmpdir))
196
+ expect(file_list(tmpdir, files)).to all be_a_regular_file
197
+ end
198
+ end
199
+
200
+ it 'writes results and stats to files' do
201
+ Dir.mktmpdir do |tmpdir|
202
+ results_files = %w(size_4_branch_and_bound.results size_10_branch_and_bound.results)
203
+ stats_files = %w(size_4_branch_and_bound.stats size_10_branch_and_bound.stats)
204
+ files = results_files + stats_files
205
+ cli.run(args_in_dataset_out_file('-b', tmpdir))
206
+ expect(file_list(tmpdir, files)).to all be_a_regular_file
207
+ end
208
+ end
209
+
210
+ it 'writes results and stats to stdout' do
211
+ results_files = %w(size_4_branch_and_bound.results size_10_branch_and_bound.results)
212
+ stats_files = %w(size_4_branch_and_bound.stats size_10_branch_and_bound.stats)
213
+ files = results_files + stats_files
214
+ files.each do |f|
215
+ expect { cli.run(args('-b test/datasets/size_4.dataset test/datasets/size_10.dataset')) }.to output(/#{f}/).to_stdout
216
+ end
217
+ end
218
+
219
+ it 'adds relative error if an exact solving method is selected' do
220
+ expect { cli.run(args('-r test/datasets/size_4.dataset test/datasets/size_10.dataset')) }.not_to output(/avg_relative_error/).to_stdout
221
+ expect { cli.run(args('-f -e 0.5 -r test/datasets/size_4.dataset test/datasets/size_10.dataset')) }.not_to output(/avg_relative_error/).to_stdout
222
+ expect { cli.run(args('-b -r test/datasets/size_4.dataset test/datasets/size_10.dataset')) }.to output(/avg_relative_error/).to_stdout
223
+ expect { cli.run(args('-d -r test/datasets/size_4.dataset test/datasets/size_10.dataset')) }.to output(/avg_relative_error/).to_stdout
224
+ end
225
+
226
+ it 'produces correct results' do
227
+ Dir.mktmpdir do |tmpdir|
228
+ results_files = %w(size_10_dynamic_programming.results
229
+ size_10_fptas.results
230
+ size_10_heuristic.results
231
+ size_4_dynamic_programming.results
232
+ size_4_fptas.results
233
+ size_4_heuristic.results)
234
+ stats_files = %w(size_10_dynamic_programming.stats
235
+ size_10_fptas.stats
236
+ size_10_heuristic.stats
237
+ size_4_dynamic_programming.stats
238
+ size_4_fptas.stats
239
+ size_4_heuristic.stats)
240
+ good_results_files = results_files.map { |f| File.join('test/output_logs', f) }
241
+ good_stats_files = stats_files.map { |f| File.join('test/output_logs', f) }
242
+ files = results_files + stats_files
243
+ cli.run(args_in_dataset_out_file('-b -d -r -f -e 0.5', tmpdir))
244
+ expect(file_list(tmpdir, files)).to all be_a_regular_file
245
+ expect(file_list(tmpdir, results_files)).to all be_a_valid_results_file
246
+ expect(file_list(tmpdir, stats_files)).to all be_a_valid_stats_file
247
+
248
+ file_list(tmpdir, results_files).each_with_index do |f, i|
249
+ expect(f).to be_a_equal_to_results_file(good_results_files[i])
250
+ end
251
+
252
+ file_list(tmpdir, stats_files).each_with_index do |f, i|
253
+ expect(f).to be_a_equal_to_stats_file(good_stats_files[i])
254
+ end
255
+ end
256
+
257
+ end
258
+
259
+ end
260
+
261
+ end