knapsack_solver 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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