diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..132a47f --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,18 @@ +name: Ruby + +on: [push,pull_request] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.0.0 + - name: Run the default task + run: | + gem install bundler -v 2.2.3 + bundle install + bundle exec rake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e192b8f --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ +.DS_Store +.tool_versions diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..ba37713 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,17 @@ +AllCops: + Exclude: + - 'test/fib.rb' + +Style/InfiniteLoop: + Enabled: false + +Style/StringLiterals: + Enabled: false + EnforcedStyle: double_quotes + +Style/StringLiteralsInInterpolation: + Enabled: true + EnforcedStyle: double_quotes + +Layout/LineLength: + Max: 120 diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..b377aa9 --- /dev/null +++ b/Gemfile @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +# Specify your gem's dependencies in pairing_heap.gemspec +gemspec diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..496afbd --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,21 @@ +PATH + remote: . + specs: + pairing_heap (0.1.0) + +GEM + remote: https://rubygems.org/ + specs: + minitest (5.14.3) + rake (13.0.3) + +PLATFORMS + x86_64-darwin-20 + +DEPENDENCIES + minitest (~> 5.0) + pairing_heap! + rake (~> 13.0) + +BUNDLED WITH + 2.2.3 diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..8d77be7 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2021 Marcin Henryk Bartkowiak + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ed40273 --- /dev/null +++ b/README.md @@ -0,0 +1,426 @@ +# PairingHeap + +PairingHeap is a pure Ruby priority queue implementation using a pairing heap as the underlying data structure. While a pairing heap is asymptotically less efficient than the Fibonacci heap, it is usually faster in practice. This makes it a popular choice for Prim's MST or Dijkstra's algorithm implementations. + +## Installation + +Add this line to your application's Gemfile: + +```ruby +gem 'pairing_heap' +``` + +And then execute: + + $ bundle install + +Or install it yourself as: + + $ gem install pairing_heap + +## Usage +```ruby +require 'pairing_heap' + +# Min priority queue +best_defenses = PairingHeap::MinPriorityQueue.new +best_defenses.push('Chelsea', 24) +best_defenses.push('City', 30) +best_defenses.push('Tottenham', 25) +best_defenses.any? # => true +best_defenses.size # => 3 +best_defenses.decrease_key('City', 15) +best_defenses.min # => 'City' +best_defenses.pop # => 'City' +best_defenses.extract_min # => 'Chelsea' +best_defenses.extract_min # => 'Tottenham' +best_defenses.any? # => false + +# Max priority queue +best_teams = PairingHeap::MaxPriorityQueue.new +best_teams.push('City', 56) +best_teams.push('United', 46) +best_teams.push('Leicester', 46) +best_teams.increase_key('Leicester', 47) +best_teams.max # => 'City' +best_teams.pop # => 'City' +best_teams.extract_max # => 'Leicester' + +# Custom comparator(it defaults to :<=.to_proc) +compare_by_length = PairingHeap::PairingHeap.new { |l, r| l.length <= r.length } +compare_by_length.push(:a, '11') +compare_by_length.push(:b, '1') +compare_by_length.push(:c, '11') +compare_by_length.change_priority(:c, '') +compare_by_length.peek # => :c +compare_by_length.pop # => :c +compare_by_length.pop # => :b +compare_by_length.pop # => :a + +# SafeChangePriortyQueue +queue = PairingHeap::SafeChangePriorityQueue.new +queue.push(:a, 1) +queue.push(:b, 2) +queue.change_priority(:a, 3) # This works and does not throw an exception +queue.peek # => :b +``` +See also [test/performance_dijkstra.rb](./test/performance_dijkstra.rb) for a Dijkstra algorithm implementation. +### Changes from lazy_priority_queue +This API is a drop-in replacement of [lazy_priority_queue](https://github.com/matiasbattocchia/lazy_priority_queue) with the following differences: + +* Custom comparator provided to constructur, compares weights, not internal nodes +* `change_priority` returns `self` instead of the first argument +* `enqueue` returns `self` instead of the first argument +* Queue classes are in the `PairingHeap` namespace, so `require 'pairing_heap` does not load `MinPriorityQueue` to the global scope +* `top_condidition` constructor argument is removed + +## Time Complexity +| Operation | Time complexity | Amortized time complexity | +| --------------- | --------------- | ------------------------- | +| enqueue | O(1) | O(1) | +| peek | O(1) | O(1) | +| change_priority | O(1) | o(log n) | +| dequeue | O(n) | O(log n) | +| delete | O(n) | O(log n) | + +## Benchmarks +I picked the two fastest pure Ruby priority queue implementations I was aware of for the comparison: + +* [lazy_priority_queue](https://github.com/matiasbattocchia/lazy_priority_queue) that uses a lazy binomial heap. This is probably the most popular option, used for example in [RGL](https://github.com/monora/rgl/) +* Pure Ruby implementation of Fibonacci Heap from [priority-queue](https://github.com/supertinou/priority-queue) ([link to source](https://github.com/supertinou/priority-queue/blob/master/lib/priority_queue/ruby_priority_queue.rb)) + +All tests except for the third one were executed by [benchmark-ips](https://github.com/evanphx/benchmark-ips) with parameters `time = 180` and `warmup = 30`, on an `Intel(R) Core(TM) i7-10700K CPU @ 3.80GHz`. +### Stress test without changing priority test(N = 1000) [source code](./test/performance.rb) +Original performance test from [lazy_priority_queue](https://github.com/matiasbattocchia/lazy_priority_queue) +> A stress test of 1,000,000 operations: starting with 1,000 pushes/0 pops, following 999 pushes/1 pop, and so on till 0 pushes/1000 pops. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ruby 3.0.0p0 (2020-12-25 revision 95aff21468) [x86_64-darwin20]
LibraryIterationsSecondsIterations per second
pairing_heap1460.5645950.231
lazy_priority_queue862.4898190.128(1.81x slower)
Fibonacci868.7191940.116(1.99x slower)
jruby 9.2.14.0 (2.5.7) 2020-12-08 ebe64bafb9 OpenJDK 64-Bit Server VM 15.0.2+7 on 15.0.2+7 +jit [darwin-x86_64]
LibraryIterationsSecondsIterations per second
pairing_heap1761.1957940.278
lazy_priority_queue1464.3759270.218(1.28x slower)
Fibonacci967.4153580.134(2.08x slower)
+ +### Stress test with changing priority(N = 1000) [source code](./test/performance_with_change_priority.rb) +A stress test of 2,000,000 operations: starting with 1,000 pushes/1000 change_priorities/0 pops, following 999 pushes/999 change_priorities/1 pop, and so on till 0 pushes/0 change_priorities/1000 pops. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ruby 3.0.0p0 (2020-12-25 revision 95aff21468) [x86_64-darwin20]
LibraryIterationsSecondsIterations per second
pairing_heap1360.2801650.216
lazy_priority_queue867.414861s0.119(1.82x slower)
Fibonacci761.0674360.115(1.88x slower)
jruby 9.2.14.0 (2.5.7) 2020-12-08 ebe64bafb9 OpenJDK 64-Bit Server VM 15.0.2+7 on 15.0.2+7 +jit [darwin-x86_64]
LibraryIterationsSecondsIterations per second
pairing_heap1662.5196770.256
lazy_priority_queue1363.8327330.204(1.26x slower)
Fibonacci860.2506580.133(1.93x slower)
+ +### Stress test with changing priority(N = 10) [source code](./test/performance_with_change_priority.rb) +A stress test of 200 operations: starting with 10 pushes/10 change_priorities/0 pops, following 9 pushes/9 change_priorities/1 pop, and so on till 0 pushes/0 change_priorities/10 pops. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ruby 3.0.0p0 (2020-12-25 revision 95aff21468) [x86_64-darwin20]
LibraryIterations per second
pairing_heap5991.2
Fibonacci3803.5(1.58x slower)
lazy_priority_queue3681.9(1.64x slower)
jruby 9.2.14.0 (2.5.7) 2020-12-08 ebe64bafb9 OpenJDK 64-Bit Server VM 15.0.2+7 on 15.0.2+7 +jit [darwin-x86_64]
LibraryIterations per second
pairing_heap6784.3
lazy_priority_queue6044.5(1.12x slower)
Fibonacci4070.5(1.67x slower)
+ +### Dijkstra's algorithm with RGL [source code](./test/performance_rgl.rb) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ruby 3.0.0p0 (2020-12-25 revision 95aff21468) [x86_64-darwin20]
LibraryIterationsSecondsIterations per second
pairing_heap764.7685260.108
lazy_priority_queue663.2780910.095(1.14x slower)
Fibonacci665.8980810.091(1.19x slower)
jruby 9.2.14.0 (2.5.7) 2020-12-08 ebe64bafb9 OpenJDK 64-Bit Server VM 15.0.2+7 on 15.0.2+7 +jit [darwin-x86_64]
LibraryIterationsSecondsIterations per second
pairing_heap1260.2775670.199
lazy_priority_queue1261.2383950.196(1.02x slower)
Fibonacci1062.6873780.160(1.25x slower)
+ +### Simple Dijkstra's algorithm implementation [source code](./test/performance_dijkstra.rb) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ruby 3.0.0p0 (2020-12-25 revision 95aff21468) [x86_64-darwin20]
LibraryIterationsSecondsIterations per second
pairing_heap2060.0283800.334
Fibonacci1064.4713030.155(2.14x slower)
lazy_priority_queue965.9866180.136(2.45x slower)
jruby 9.2.14.0 (2.5.7) 2020-12-08 ebe64bafb9 OpenJDK 64-Bit Server VM 15.0.2+7 on 15.0.2+7 +jit [darwin-x86_64]
LibraryIterationsSecondsIterations per second
pairing_heap2161.7272590.340
lazy_priority_queue1463.4368630.221(1.54x slower)
Fibonacci1062.4476620.160(2.12x slower)
+ +### Summary + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ruby 3.0.0p0 (2020-12-25 revision 95aff21468) [x86_64-darwin20]
LibrarySlower geometric mean
pairing_heap1
Fibonacci1.720x slower
lazy_priority_queue1.721x slower
jruby 9.2.14.0 (2.5.7) 2020-12-08 ebe64bafb9 OpenJDK 64-Bit Server VM 15.0.2+7 on 15.0.2+7 +jit [darwin-x86_64]
LibrarySlower geometric mean
pairing_heap1
lazy_priority_queue1.23x slower
Fibonacci1.78x slower
+ +## Development + +After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. + +To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). + +## Contributing + +Bug reports and pull requests are welcome on GitHub at https://github.com/mhib/pairing_heap. + +## License + +The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..4ee764e --- /dev/null +++ b/Rakefile @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "bundler/gem_tasks" +require "rake/testtask" + +Rake::TestTask.new(:test) do |t| + t.libs << "test" + t.libs << "lib" + t.test_files = FileList["test/**/*_test.rb"] +end + +task default: %i[test] diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..090b90e --- /dev/null +++ b/bin/console @@ -0,0 +1,15 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "bundler/setup" +require "pairing_heap" + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +# (If you use this, don't forget to add pry to your Gemfile!) +# require "pry" +# Pry.start + +require "irb" +IRB.start(__FILE__) diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..dce67d8 --- /dev/null +++ b/bin/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle install + +# Do any other automated setup that you need to do here diff --git a/lib/pairing_heap.rb b/lib/pairing_heap.rb new file mode 100644 index 0000000..3643fed --- /dev/null +++ b/lib/pairing_heap.rb @@ -0,0 +1,246 @@ +# frozen_string_literal: true + +module PairingHeap + # Pairing heap data structure implementation + # @see https://en.wikipedia.org/wiki/Pairing_heap + class PairingHeap + class Node + attr_accessor :elem, :priority, :subheaps, :parent, :prev_sibling, :next_sibling + def initialize(elem, priority, subheaps, parent, prev_sibling, next_sibling) + @elem = elem + @priority = priority + @subheaps = subheaps + @parent = parent + @prev_sibling = prev_sibling + @next_sibling = next_sibling + end + end + private_constant :Node + + # @param &block Optional heap property priority comparator. `<:=.to_proc` by default + def initialize(&block) + @root = nil + @nodes = {} + @order = block || :<=.to_proc + end + + # Pushes element to the heap. + # Time Complexity: O(1) + # @param elem Element to be pushed + # @param priority Priority of the element + # @raise [ArgumentError] if the element is already in the heap + # @return [PairingHeap] + def push(elem, priority) + raise ArgumentError, "Element already in the heap" if @nodes.key?(elem) + + node = Node.new(elem, priority, nil, nil, nil, nil) + @nodes[elem] = node + @root = meld(@root, node) + self + end + alias enqueue push + + # Returns the element at the top of the heap + # Time Complexity: O(1) + def peek + @root&.elem + end + + # Time Complexity: O(1) + # @return [Boolean] + def empty? + @root.nil? + end + + # Time Complexity: O(1) + # @return [Boolean] + def any? + !@root.nil? + end + + # Time Complexity: O(1) + # @return [Integer] + def size + @nodes.size + end + alias length size + + # Removes element from the top of the heap + # Time Complexity: O(N) + # Amortized time Complexity: O(log(N)) + # @raise [ArgumEntError] if the heap is empty + # @return [PairingHeap] + def pop + raise ArgumentError, "Cannot remove from an empty heap" if @root.nil? + + elem = @root.elem + @nodes.delete(elem) + @root = merge_pairs(@root.subheaps) + if @root + @root.parent = nil + @root.next_sibling = nil + @root.prev_sibling = nil + end + elem + end + alias dequeue pop + + # Changes a priority of element to a more prioritary one + # Time Complexity: O(1) + # Amortized Time Complexity: o(log(N)) + # @param elem Element + # @param priority New priority + # @raise [ArgumentError] if the element heap is not in heap or the new priority is less prioritary + # @return [PairingHeap] + def change_priority(elem, priority) + node = @nodes[elem] + raise ArgumentError, "Provided element is not in heap" if node.nil? + unless @order[priority, node.priority] + raise ArgumentError, "Priority cannot be changed to a less prioritary value." + end + + node.priority = priority + return if node.parent.nil? + return if @order[node.parent.priority, node.priority] + + remove_from_parents_list(node) + @root = meld(node, @root) + @root.parent = nil + self + end + + # Removes element from the top of the heap + # Time Complexity: O(N) + # Amortized Time Complexity: O(log(N)) + # @raise [ArgumentError] if the element heap is not in heap + # @return [PairingHeap] + def delete(elem) + node = @nodes[elem] + raise ArgumentError, "Provided element is not in heap" if node.nil? + + @nodes.delete(elem) + if node.parent.nil? + @root = merge_pairs(node.subheaps) + else + remove_from_parents_list(node) + new_heap = merge_pairs(node.subheaps) + if new_heap + new_heap.prev_sibling = nil + new_heap.next_sibling = nil + end + @root = meld(new_heap, @root) + end + @root&.parent = nil + self + end + + private + + def remove_from_parents_list(node) + if node.prev_sibling + node.prev_sibling.next_sibling = node.next_sibling + node.next_sibling.prev_sibling = node.prev_sibling if node.next_sibling + elsif node.parent.subheaps.equal?(node) + node.parent.subheaps = node.next_sibling + node.next_sibling.prev_sibling = nil if node.next_sibling + elsif node.next_sibling + node.next_sibling.prev_sibling = nil + end + node.prev_sibling = nil + node.next_sibling = nil + end + + def meld(left, right) + return right if left.nil? + return left if right.nil? + + if @order[left.priority, right.priority] + parent = left + child = right + else + parent = right + child = left + end + child.next_sibling = parent.subheaps + parent.subheaps = child + child.next_sibling.prev_sibling = child if child.next_sibling + child.prev_sibling = nil + child.parent = parent + parent + end + + # Non-recursive implementation of method described in https://en.wikipedia.org/wiki/Pairing_heap#delete-min + def merge_pairs(heaps) + return nil if heaps.nil? + return heaps if heaps.next_sibling.nil? + + # [H1, H2, H3, H4, H5, H6, H7] => [H1H2, H3H4, H5H6, H7] + stack = [] + current = heaps + while current + prev = current + current = current.next_sibling + unless current + stack << prev + break + end + next_val = current.next_sibling + stack << meld(prev, current) + current = next_val + end + + # [H1H2, H3H4, H5H6, H7] + # [H1H2, H3H4, H5H67] + # [H1H2, H3H45H67] + # [H1H2H3H45H67] + # return H1H2H3H45H67 + while true + right = stack.pop + return right if stack.empty? + + left = stack.pop + stack << meld(left, right) + end + end + end + + # Priority queue where the smallest priority is the most prioritary + class MinPriorityQueue < PairingHeap + def initialize + super(&:<=) + end + + alias decrease_key change_priority + alias min peek + alias extract_min dequeue + end + + # Priority queue where the highest priority is the most prioritary + class MaxPriorityQueue < PairingHeap + def initialize + super(&:>=) + end + + alias increase_key change_priority + alias max peek + alias extract_max dequeue + end + + # Priority queue with change_priority, that accepts changing to a less prioritary priority + class SafeChangePriorityQueue < PairingHeap + # Changes a priority of the element to a more prioritary one + # Time Complexity: O(N) + # Amortized Time Complexity: O(log(N)) + # @raise [ArgumentError] if the element heap is not in the heap + # @return [PairingHeap] + def change_priority(elem, priority) + raise ArgumentError, "Provided element is not in heap" unless @nodes.key?(elem) + if !@order[priority, @nodes[elem].priority] + delete(elem) + push(elem, priority) + else + super(elem, priority) + end + end + end +end diff --git a/lib/pairing_heap/version.rb b/lib/pairing_heap/version.rb new file mode 100644 index 0000000..b56d403 --- /dev/null +++ b/lib/pairing_heap/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module PairingHeap + VERSION = "0.1.0" +end diff --git a/pairing_heap.gemspec b/pairing_heap.gemspec new file mode 100644 index 0000000..ebe67d1 --- /dev/null +++ b/pairing_heap.gemspec @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require_relative "lib/pairing_heap/version" + +Gem::Specification.new do |spec| + spec.name = "pairing_heap" + spec.version = PairingHeap::VERSION + spec.authors = ["Marcin Henryk Bartkowiak"] + spec.email = ["mhbartkowiak@gmail.com"] + + spec.summary = "Performant priority queue in pure ruby with support for changing priority" + spec.description = "Performant priority queue in pure ruby with support for changing priority using pairing heap data structure" + spec.homepage = "https://github.com/mhib/pairing_heap" + spec.license = "MIT" + spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0") + + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = spec.homepage + + # Specify which files should be added to the gem when it is released. + # The `git ls-files -z` loads the files in the RubyGem that have been added into git. + spec.files = Dir.chdir(File.expand_path(__dir__)) do + `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) } + end + spec.bindir = "exe" + spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] + + # Uncomment to register a new dependency of your gem + # spec.add_dependency "example-gem", "~> 1.0" + + spec.add_development_dependency "minitest", "~> 5.0" + spec.add_development_dependency "rake", "~> 13.0" + + # For more information and examples about making a new gem, checkout our + # guide at: https://bundler.io/guides/creating_gem.html +end diff --git a/test/.tool-versions b/test/.tool-versions new file mode 100644 index 0000000..2c0c270 --- /dev/null +++ b/test/.tool-versions @@ -0,0 +1 @@ +ruby 3.0.0 diff --git a/test/fib.rb b/test/fib.rb new file mode 100644 index 0000000..9e1d069 --- /dev/null +++ b/test/fib.rb @@ -0,0 +1,515 @@ +# From: https://github.com/supertinou/priority-queue/blob/master/lib/priority_queue/ruby_priority_queue.rb +# Pure ruby Priority Queue +class RubyPriorityQueue + + include Enumerable + + private + + # + def link_nodes(b1, b2) + return link_nodes(b2, b1) if b2.priority < b1.priority + + b2.parent = b1 + child = b1.child + b1.child = b2 + if child + b2.left = child.left + b2.left.right = b2 + b2.right = child + child.left = b2 + else + b2.left = b2 + b2.right = b2 + end + b1.degree += 1 + b2.mark = false # TODO: Check if this is correct, or if b1 should be marked as false + return b1 + end + + # Does not change length + def delete_first + return nil unless @rootlist + + result = @rootlist + if result == result.right + @min = @rootlist = nil + else + @rootlist = result.right + @rootlist.left = result.left + @rootlist.left.right = @rootlist + + result.right = result.left = result + end + return result; + end + + def cut_node(n) + return self unless n.parent + n.parent.degree -= 1 + if n.parent.child == n + if n.right == n + n.parent.child = nil + else + n.parent.child = n.right; + end + end + n.parent = nil + n.right.left = n.left + n.left.right = n.right + + n.right = @rootlist + n.left = @rootlist.left + @rootlist.left.right = n + @rootlist.left = n + + n.mark = false + + return self + end + + # Does not change length + def insert_tree(tree) + if @rootlist == nil + @rootlist = @min = tree + else + l = @rootlist.left + l.right = tree + @rootlist.left = tree + tree.left = l + tree.right = @rootlist + @min = tree if tree.priority < @min.priority + end + self + end + + def consolidate + return self if self.empty? + array_size = (2.0 * Math.log(self.length) / Math.log(2) + 1.0).ceil + tree_by_degree = Array.new(array_size) + + while n = delete_first + while n1 = tree_by_degree[n.degree] + tree_by_degree[n.degree] = nil; + n = link_nodes(n, n1); + end + tree_by_degree[n.degree] = n; + end + + @rootlist = @min = nil; + tree_by_degree.each do | tree | + next unless tree + insert_tree(tree) + end + self + end + + # Node class used internally + class Node # :nodoc: + attr_accessor :parent, :left, :right, :key, :priority, :degree, :mark + attr_reader :child + + def child=(c) + raise "Circular Child" if c == self + raise "Child is neighbour" if c == self.right + raise "Child is neighbour" if c == self.left + @child = c + end + + def to_dot(only_down = false, known_nodes = []) + p known_nodes.map { | n | n.dot_id } + p self.dot_id + result = [] + if only_down + raise "Circular #{caller.inspect}" if known_nodes.include?(self) + known_nodes << self + + result << "#{dot_id} [label=\"#{@key}: #{@priority}\"];" + l = " " + #l << "#{@left.dot_id} <- #{dot_id}; " if @left + l << "#{dot_id} -> #{@left.dot_id} [constraint=false]; " if @left and @left.dot_id < self.dot_id + l << "#{dot_id} -> #{@right.dot_id} [constraint=false];\t\t\t\t/*neighbours*/" if @right and @right.dot_id <= self.dot_id + result << l + result << " #{dot_id} -> #{@child.dot_id}; //child" if @child + result << @child.to_dot(false, known_nodes) if @child + else + n = self + begin + result.concat(n.to_dot(true, known_nodes)) + n = n.right + end while n != self + end + result.flatten.map{|r| " " << r} + end + + def dot_id + "N#{@key}" + end + + def initialize(key, priority) + @key = key; @priority = priority; @degree = 0 + end + end + + public + + # Returns the number of elements of the queue. + # + # q = PriorityQueue.new + # q.length #=> 0 + # q[0] = 1 + # q.length #=> 1 + attr_reader :length + + # Create a new, empty PriorityQueue + def initialize + @nodes = Hash.new + @rootlist = nil + @min = nil + @length = 0 + end + + # Print a priority queue as a dot-graph. The output can be fed to dot from the + # vizgraph suite to create a tree depicting the internal datastructure. + def to_dot + r = ["digraph fibheap {"] + #r << @rootlist.to_dot.join("\n") if @rootlist + r << "ROOT -> #{@rootlist.dot_id};" if @rootlist + @nodes.to_a.sort.each do | (_, n) | + r << " #{n.dot_id} [label=\"#{n.key}: #{n.priority}\"];" + r << " #{n.dot_id} -> #{n.right.dot_id} [constraint=false];" if n.right# and n.dot_id < n.right.dot_id + r << " #{n.dot_id} -> #{n.left.dot_id} [constraint=false];" if n.left #and n.dot_id < n.left.dot_id + r << " #{n.dot_id} -> #{n.child.dot_id}" if n.child + end + r << "}" + r.join("\n") + r + end + + # Call dot and gv displaying the datstructure + def display_dot + puts to_dot + system "echo '#{to_dot}' | twopi -Tps -Groot=ROOT -Goverlap=false> /tmp/dotfile.ps; gv /tmp/dotfile.ps" + end + + # call-seq: + # [key] = priority + # change_priority(key, priority) + # push(key, priority) + # + # Set the priority of a key. + # + # q = PriorityQueue.new + # q["car"] = 50 + # q["train"] = 50 + # q["bike"] = 10 + # q.min #=> ["bike", 10] + # q["car"] = 0 + # q.min #=> ["car", 0] + def change_priority(key, priority) + return push(key, priority) unless @nodes[key] + + n = @nodes[key] + if n.priority < priority # Priority was increased. Remove the node and reinsert. + self.delete(key) + self.push(key, priority); + return self + end + n.priority = priority; + @min = n if n.priority < @min.priority + + return self if !n.parent or n.parent.priority <= n.priority # Already in rootlist or bigger than parent + begin # Cascading Cuts + p = n.parent + cut_node(n) + n = p + end while n.mark and n.parent + n.mark = true if n.parent + + self + end + alias decrease_key change_priority + + # Add an object to the queue. + def push(key, priority) + return change_priority(key, priority) if @nodes[key] + @nodes[key] = node = Node.new(key, priority) + @min = node if !@min or priority < @min.priority + if not @rootlist + @rootlist = node + node.left = node.right = node + else + node.left = @rootlist.left + node.right = @rootlist + @rootlist.left.right = node + @rootlist.left = node + end + @length += 1 + self + end + + # Returns true if the array is empty, false otherwise. + def empty? + @rootlist.nil? + end + + # call-seq: + # [key] -> priority + # + # Return the priority of a key or nil if the key is not in the queue. + # + # q = PriorityQueue.new + # (0..10).each do | i | q[i.to_s] = i end + # q["5"] #=> 5 + # q[5] #=> nil + def [](key) + @nodes[key] and @nodes[key].priority + end + + # call-seq: + # has_key? key -> boolean + # + # Return false if the key is not in the queue, true otherwise. + # + # q = PriorityQueue.new + # (0..10).each do | i | q[i.to_s] = i end + # q.has_key("5") #=> true + # q.has_key(5) #=> false + def has_key?(key) + @nodes.has_key?(key) + end + + alias :[]= :push + + # Call the given block with each [key, priority] pair in the queue + # + # Beware: Changing the queue in the block may lead to unwanted behaviour and + # even infinite loops. + def each + @nodes.each do | key, node | + yield(key, node.priority) + end + end + + # call-seq: + # min -> [object, priority] + # + # Return the pair [object, priority] with minimal priority or nil when the + # queue is empty. + # + # q = PriorityQueue.new + # q["a"] = 10 + # q["b"] = 20 + # q.min #=> ["a", 10] + # q.delete_min #=> ["a", 10] + # q.min #=> ["b", 20] + # q.delete_min #=> ["b", 20] + # q.min #=> nil + def min + [@min.key, @min.priority] rescue nil + end + + # call-seq: + # min_key -> object + # + # Return the key that has the minimal priority or nil when the queue is empty. + # + # q = PriorityQueue.new + # q["a"] = 10 + # q["b"] = 20 + # q.min_key #=> "a" + # q.delete_min #=> ["a", 10] + # q.min_key #=> "b" + # q.delete_min #=> ["b", 20] + # q.min_key #=> nil + def min_key + @min.key rescue nil + end + + # call-seq: + # min_priority -> priority + # + # Return the minimal priority or nil when the queue is empty. + # + # q = PriorityQueue.new + # q["a"] = 10 + # q["b"] = 20 + # q.min_priority #=> 10 + # q.delete_min #=> ["a", 10] + # q.min_priority #=> 20 + # q.delete_min #=> ["b", 20] + # q.min_priority #=> nil + def min_priority + @min.priority rescue nil + end + + # call-seq: + # delete(key) -> [key, priority] + # delete(key) -> nil + # + # Delete a key from the priority queue. Returns nil when the key was not in + # the queue and [key, priority] otherwise. + # + # q = PriorityQueue.new + # (0..10).each do | i | q[i.to_s] = i end + # q.delete(5) #=> ["5", 5] + # q.delete(5) #=> nil + def delete(key) + return nil unless n = @nodes.delete(key) + + if n.child + c = n.child + e = n.child + begin + r = c.right + cut_node(c) + c = r + end while c != e + end + cut_node(n) if n.parent + + if n == n.right + @min = nil; + @rootlist = nil; + else + @rootlist = n.right if @rootlist == n + if @min == n + n1 = n.right + @min = n1 + begin + @min = n1 if n1.priority < @min.priority + n1 = n1.right + end while(n1 != n); + end + n.right.left = n.left + n.left.right = n.right + n.left = n + n.right = n + end + @length -= 1 + return [n.key, n.priority] + end + + # call-seq: + # delete_min_return_key -> key + # + # Delete key with minimal priority and return the key + # + # q = PriorityQueue.new + # q["a"] = 1 + # q["b"] = 0 + # q.delete_min_return_key #=> "b" + # q.delete_min_return_key #=> "a" + # q.delete_min_return_key #=> nil + def delete_min_return_key + delete_min[0] rescue nil + end + + # call-seq: + # delete_min_return_priority -> priority + # + # Delete key with minimal priority and return the priority value + # + # q = PriorityQueue.new + # q["a"] = 1 + # q["b"] = 0 + # q.delete_min_return_priority #=> 0 + # q.delete_min_return_priority #=> 1 + # q.delete_min_return_priority #=> nil + def delete_min_return_priority + delete_min[1] rescue nil + end + + # call-seq: + # delete_min -> [key, priority] + # + # Delete key with minimal priority and return [key, priority] + # + # q = PriorityQueue.new + # q["a"] = 1 + # q["b"] = 0 + # q.delete_min #=> ["b", 0] + # q.delete_min #=> ["a", 1] + # q.delete_min #=> nil + def delete_min + return nil if self.empty? + result = self.min + + @nodes.delete(@min.key) + + if @length == 1 + @rootlist = @min = nil + @length = 0 + else + min = @min + if @min == @rootlist # If the rootlist is anchored at the minimum, shift to the right + if @rootlist == @rootlist.right + @rootlist = @min = nil + else + @rootlist = @min = @min.right + end + end + min.left.right = min.right; + min.right.left = min.left; + min.left = min.right = min; + if min.child + # Kinder und Eltern trennen, Markierung aufheben + n = min.child; + begin + n.parent = nil; + n.mark = false; + n = n.right; + end while n != min.child + + # Kinder einfügen + if @rootlist + l1 = @rootlist.left + l2 = n.left + + l1.right = n + n.left = l1 + l2.right = @rootlist + @rootlist.left = l2 + else + @rootlist = n + end + end + + # Größe anpassen + @length -= 1 + + # Wieder aufhübschen + consolidate + end + + result + end + alias pop delete_min_return_key + + # Returns a string representation of the priority queue. + def inspect + "" + end + + def initialize_copy(copy) + copy_nodes = @nodes + @nodes = {} + + copy_nodes.each do | (_, cn) | + n = @nodes[cn.key] = Node.new(cn.key, cn.priority) + n.mark = cn.mark + n.degree = cn.degree + end + + copy_nodes.each do | (_, cn) | + n = @nodes[cn.key] + n.left = @nodes[cn.left.key] if cn.left + n.right = @nodes[cn.right.key] if cn.right + n.parent = @nodes[cn.parent.key] if cn.parent + n.child = @nodes[cn.child.key] if cn.child + end + @rootlist = @nodes[@rootlist.key] if @rootlist + @min = @nodes[@min.key] if @min + self + end +end \ No newline at end of file diff --git a/test/pairing_heap_test.rb b/test/pairing_heap_test.rb new file mode 100644 index 0000000..1ef7520 --- /dev/null +++ b/test/pairing_heap_test.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require "test_helper" + +describe PairingHeap do + describe PairingHeap::PairingHeap do + it 'works correctly with random usage' do + queue = PairingHeap::PairingHeap.new + class Element + attr_accessor :priority + def initialize(priority) + @priority = priority + end + end + + items = [] + + 5_000.times do |id| + _(queue.empty?).must_equal(items.empty?) + _(-> { queue.peek }).must_be_silent + _(queue.size).must_equal(items.size) + + if rand(2).zero? + priority = rand(1000) + item = Element.new(priority) + queue.enqueue(item, item.priority) + items << item + end + + next if items.empty? + + if rand(2).zero? + item = items.sample + item.priority -= rand(1000) + queue.change_priority(item, item.priority) + end + + if rand(4).zero? + items.delete(queue.dequeue) + end + + if items.any? && rand(6).zero? + sample = items.sample + queue.delete(sample) + items.delete(sample) + end + + end + + sorted_items = [] + sorted_items << queue.pop until queue.empty? + sorted_items.map!(&:priority) + + _(sorted_items).must_equal(sorted_items.sort) + end + + it 'does not crash with a large number of consecutive pushes' do + queue = PairingHeap::PairingHeap.new + 1.upto(5_000_000) do |i| + queue.push(i, i) + end + _(queue.pop).must_equal(1) + end + + it 'throws when trying to change priority to a less prioritary one' do + queue = PairingHeap::PairingHeap.new + queue.push(1, 1) + _(-> { queue.change_priority(1, 2) }).must_raise(ArgumentError) + end + end + + describe PairingHeap::MinPriorityQueue do + it 'sorts correctly' do + queue = PairingHeap::MinPriorityQueue.new + array = (1..10).to_a + array.each { |i| queue.push(i, i) } + result = [] + result << queue.pop while queue.any? + _(result).must_equal(array) + end + end + + describe PairingHeap::MaxPriorityQueue do + it 'sorts correctly' do + queue = PairingHeap::MaxPriorityQueue.new + array = (1..10).to_a + array.each { |i| queue.push(i, i) } + result = [] + result << queue.pop while queue.any? + _(result).must_equal(array.reverse) + end + end + + describe PairingHeap::SafeChangePriorityQueue do + it 'does not throw when trying to change priority to a less prioritary one' do + queue = PairingHeap::SafeChangePriorityQueue.new(&:<=) + queue.push(1, 1) + queue.push(2, 2) + _(-> { queue.change_priority(1, 3) }).must_be_silent + _(queue.pop).must_equal(2) + _(queue.pop).must_equal(1) + end + end +end diff --git a/test/performance.rb b/test/performance.rb new file mode 100644 index 0000000..e3a1c5c --- /dev/null +++ b/test/performance.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true +# Based on https://github.com/matiasbattocchia/lazy_priority_queue/blob/master/test/performance.rb +require 'benchmark/ips' +require_relative '../lib/pairing_heap' +require 'lazy_priority_queue' +require_relative 'fib' + +N = 1_000 +def iterator(push, pop) + N.times do |i| + (N - i).times do |j| + push.call i.to_s + ':' + j.to_s + end + + i.times do + pop.call + end + end +end + +Benchmark.ips do |bm| + bm.time = 60 + bm.warmup = 15 + + bm.report('lazy_priority_queue') do + q = MinPriorityQueue.new + iterator(->(n) { q.enqueue(n, rand) }, -> { q.dequeue }) + end + + bm.report('pairing_heap') do + q = PairingHeap::MinPriorityQueue.new + iterator(->(n) { q.enqueue n, rand }, -> { q.dequeue }) + end + + bm.report('Fibonacci') do + q = RubyPriorityQueue.new + iterator(->(n) { q.push n, rand }, -> { q.delete_min }) + end + + bm.compare! +end diff --git a/test/performance_dijkstra.rb b/test/performance_dijkstra.rb new file mode 100644 index 0000000..d189145 --- /dev/null +++ b/test/performance_dijkstra.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'csv' +require 'benchmark/ips' +require_relative '../lib/pairing_heap' +require 'lazy_priority_queue' +require_relative 'fib' +Edge = Struct.new(:to, :weight) + +@neighbourhood = [] +# https://gist.githubusercontent.com/mhib/ed03bb9eb67ae871fa6f199f024379c7/raw/4acd08372b3ffec2de1982449b2a4ee9fbaa48cd/Tokyo_Edgelist.csv +CSV.foreach("Tokyo_Edgelist.csv", headers: true) do |row| + from = row["START_NODE"].to_i + to = row["END_NODE"].to_i + weight = row["LENGTH"].to_f + @neighbourhood[from] ||= [] + @neighbourhood[from] << Edge.new(to, weight) + @neighbourhood[to] ||= [] + @neighbourhood[to] << Edge.new(from, weight) +end + +def get_costs(q) + distance = Array.new(@neighbourhood.size, Float::INFINITY) + distance[1] = 0 + @neighbourhood.each_with_index do |value, vertex| + next unless value + + q.push(vertex, distance[vertex]) + end + until q.empty? + el = q.pop + @neighbourhood[el].each do |edge| + alt = distance[el] + edge.weight + if alt < distance[edge.to] + distance[edge.to] = alt + q.change_priority(edge.to, alt) + end + end + end + distance +end + +Benchmark.ips do |bm| + bm.time = 60 + bm.warmup = 15 + bm.report('pairing_heap') do + get_costs(PairingHeap::MinPriorityQueue.new) + end + + bm.report('Fibonacci') do + get_costs(RubyPriorityQueue.new) + end + + bm.report('lazy_priority_queue') do + get_costs(MinPriorityQueue.new) + end + + bm.compare! +end diff --git a/test/performance_rgl.rb b/test/performance_rgl.rb new file mode 100644 index 0000000..6b17bc2 --- /dev/null +++ b/test/performance_rgl.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'rgl/dijkstra' +require 'rgl/adjacency' +require_relative '../lib/pairing_heap' +require 'csv' +require 'benchmark/ips' +require_relative 'fib' + +include RGL + +@graph = AdjacencyGraph[] +@edge_weights = {} + +# https://gist.githubusercontent.com/mhib/ed03bb9eb67ae871fa6f199f024379c7/raw/4acd08372b3ffec2de1982449b2a4ee9fbaa48cd/Tokyo_Edgelist.csv +CSV.foreach("Tokyo_Edgelist.csv", headers: true) do |row| + @graph.add_edge(row["START_NODE"].to_i, row["END_NODE"].to_i) + @edge_weights[[row["START_NODE"].to_i, row["END_NODE"].to_i]] = row["LENGTH"].to_f +end + +class PairingDijkstraAlgorithm < RGL::DijkstraAlgorithm + def init(source) + @visitor.set_source(source) + + @queue = PairingHeap::MinPriorityQueue.new + @queue.push(source, 0) + end +end + +class FibDijkstraAlgorithm < RGL::DijkstraAlgorithm + def init(source) + @visitor.set_source(source) + + @queue = RubyPriorityQueue.new + @queue.push(source, 0) + end +end + +Benchmark.ips do |bm| + bm.time = 60 + bm.warmup = 15 + + bm.report('Fibonacci') do + FibDijkstraAlgorithm.new(@graph, @edge_weights, DijkstraVisitor.new(@graph)).shortest_paths(1) + end + + bm.report('lazy_priority_queue') do + DijkstraAlgorithm.new(@graph, @edge_weights, DijkstraVisitor.new(@graph)).shortest_paths(1) + end + + bm.report('pairing_heap') do + PairingDijkstraAlgorithm.new(@graph, @edge_weights, DijkstraVisitor.new(@graph)).shortest_paths(1) + end + + bm.compare! +end diff --git a/test/performance_with_change_priority.rb b/test/performance_with_change_priority.rb new file mode 100644 index 0000000..ff65532 --- /dev/null +++ b/test/performance_with_change_priority.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true +# Based on https://github.com/matiasbattocchia/lazy_priority_queue/blob/master/test/performance.rb +require 'benchmark/ips' +require_relative '../lib/pairing_heap' +require 'lazy_priority_queue' +require_relative 'fib' + +N = 1_000 +def iterator(push, pop, change_priority) + N.times do |i| + (N - i).times do |j| + push.call i.to_s + ':' + j.to_s + end + + (N - i).times do |j| + change_priority.call(i.to_s + ':' + j.to_s) + end + + i.times do + pop.call + end + end +end + +Benchmark.ips do |bm| + bm.time = 60 + bm.warmup = 15 + + bm.report('lazy_priority_queue') do + q = MinPriorityQueue.new + iterator(->(n) { q.enqueue(n, rand) }, -> { q.dequeue }, ->(n) { q.change_priority(n, -rand) }) + end + + bm.report('pairing_heap') do + q = PairingHeap::MinPriorityQueue.new + iterator(->(n) { q.enqueue n, rand }, -> { q.dequeue }, ->(n) { q.change_priority(n, -rand) }) + end + + bm.report('Fibonacci') do + q = RubyPriorityQueue.new + iterator(->(n) { q.push n, rand }, -> { q.delete_min }, ->(n) { q.change_priority(n, -rand) }) + end + + bm.compare! +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..204713b --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift File.expand_path("../lib", __dir__) +require "pairing_heap" + +require "minitest/autorun"