My Little Blog

A blogging framework for hackers.

Ruby & Me: Converting Roman Numerals to Arabic

Yesterday was a big day. I graduated from the FlatironSchool 12-week program (yay! applause).

It’s been an intense race to pack in as much knowledge as possible in a short time-frame. As a parting gift, we were given an appropriate signifier that we are now versatile web application developers: a swiss army knife.

In recent weeks, it’s been difficult not to notice time ticking away against our task list:
– Absorb knowledge
– Complete web application for ScienceFair
– Absorb knowledge
– Practice ruby problems in preparation for Interviews
– Absorb knowledge
– Ask for 11th-hour lectures to make sure we understand topic xyz
– Absorb knowledge

In the end, as a coping mechanism, my peers and I decided that it was best to look at graduation not as the end of the road but as a new beginning. I’m happy to say that I’ve made it through the program. I’m also happy to report that I’ve largely absorbed most of my newfound knowledge.

The harrowing schedule has made it difficult for many of us to keep up with our blogs, but I’m ready and raring to go. So, I’ve decided to start a new segment called ‘Ruby & Me’ to a) force me to review my notes, b) test my understanding of basic concepts, and c) document my thought process as I work through problems. The immediate result may not be pretty, but this is what the result looks like short of refactoring. In the words of our Dean, Avi Flombaum: Make it work – Make it right – Make it fast. Then and only then: refactor.

Problem: Convert roman numerals to arabic

I’ve learned the hard way that it’s paralyzing to look too far ahead. Small successes are productive and feel great. So, I start with the following pseudo-code:

# Convert roman numerals to arabic:
# I, V, X, L, C, D, M
# NumTranslator.new("V") return arabic numeral value 5

My first goal is to create a class in which I can instantiate a new object and successfully pass my roman numeral between methods. By the way, I’ve also learned the hard way that too much coding without testing is a recipe for disaster. The last thing I want is to get bogged down debugging my code, but I’ve done it before… a lot. So, let’s see how this works out:

class NumTranslator
  def initialize(roman_numeral)
    @numeral = roman_numeral
  end

  def translator
    @numeral = "V" 
  end
end
puts NumTranslator.new("V").translator

return value #=> V

My first test passes. Awesome! Now I want to setup a simple translator that deals with all single-digit roman numerals:

def translator
    case @numeral
    when "I" then 1
    when "V" then 5
    when "X" then 10
    when "L" then 50
    when "C" then 100
    when "D" then 500
    when "M" then 1000
    else
    puts "Please enter a roman numeral"
  end 
end
puts NumTranslator.new("V").translator

return value #=> 5

Second test passes. Cool. Now I want to make sure that my full translation table works so I modify my call to accept user input:

puts "Enter roman numeral to convert to arabic numeral: "
print NumTranslator.new(gets.chomp).translator

At this point I realize that I should probably help guard my entries from my lightning fingers.

def initialize(roman_numeral)
  @numeral = roman_numeral.upcase
end

Sweet, now I can mistakenly enter a lowercase letter and it will automatically be upcased.

Now comes the tough part. Translation is easy at a single digit level, but if I enter a multiple digit roman numeral, I get the following response: “Please enter a roman numeral”. It’s time to alter my method so that I can deal with multiple letter roman numerals. I know that adjacent numerals sum to a total value e.g. “III” = 3. I also know that there are exceptions but I choose to ignore those for now.

Since each digit within a roman numeral translates to a value, I decide to update my translator method to split the input string and see if I can get “III” to return 3. I decide to use the collect method because I want to add each letter to an array. This will allow me to iterate through and add up all the values.

def translator
  @numeral = @numeral.split('').collect do |letter|
    case letter
    when "I" then 1
    when "V" then 5
    when "X" then 10
    when "L" then 50
    when "C" then 100
    when "D" then 500
    when "M" then 1000
    else
      puts "Please enter a roman numeral"
    end
  end
  @numeral.inject(:+)
end

(...)

gets.chomp #=> "III"
return value #=> 3

Excellent! It works. It’s time to get happy:

gets.chomp #=> "XI"
return value #=> 11

Yippee!

gets.chomp #=> "LV"
return value #=> 55

Hoorah!

gets.chomp #=> "XX"
return value #=> 20

Woopdeedoo!

gets.chomp #=> "IV"
return value #=> 6

Sigh. Ok, enough prolonging the inevitable. Good thing I’ve learned to manipulate my expectations: Yay! Yay! Yay! Waaaah!

Time for more pseudo-code:

# Translator method assumes each single-digit character is unique
# It splits and translates each character into a number, and adds it to an array
# Exceptions must be converted to a single-digit string and added back to the roman numeral
# Exceptions: "IV" = "4" / "IX" = "9" / "XL" = "F" (forty) / "XC" = "N" (ninety)

The translator method is already crazy long, so it seems appropriate to create a separate method to detect exceptions. I decide to start with “IV” and “IX” to keep things simple.

def detect_exception
  if @numeral.include?("IV")
    @numeral = @numeral.split("IV").push("4").inject(:+)
  elsif @numeral.include?("IX")
    @numeral = @numeral.split("IX").push("9").inject(:+)
  else
    @numeral
  end
end

Let’s test this real quick:

puts "Enter roman numeral to convert to arabic numeral: "
print NumTranslator.new(gets.chomp).detect_exception

gets.chomp #=> "XXIV"
return value #=> "XX4"

Cool, that works.

puts "Enter roman numeral to convert to arabic numeral: "
print NumTranslator.new(gets.chomp).detect_exception

gets.chomp #=> "LXIX"
return value #=> "LX9"

Awesome. Now I need to figure out how to expand this to include the other exceptions. I find that I can’t just add these as additional elsif statements because there seems to be confusion with overlapping numerals, so I try to nest them:

def detect_exception
    if @numeral.include?("IV")
      @numeral = @numeral.split("IV").push("4").inject(:+)
      if @numeral.include?("XL")
      @numeral = @numeral.split("XL").push("F").inject(:+)
      elsif @numeral.include?("XC")
      @numeral = @numeral.split("XC").push("N").inject(:+)
      else
      @numeral
      end
    elsif @numeral.include?("IX")
      @numeral = @numeral.split("IX").push("9").inject(:+)
      if @numeral.include?("XL")
      @numeral = @numeral.split("XL").push("F").inject(:+)
      elsif @numeral.include?("XC")
      @numeral = @numeral.split("XC").push("N").inject(:+)
      else
      @numeral
      end
    elsif @numeral.include?("XL")
      @numeral = @numeral.split("XL").push("F").inject(:+)
      elsif @numeral.include?("XC")
      @numeral = @numeral.split("XC").push("N").inject(:+)
    else
      @numeral
    end
  end

It’s butt ugly, but it works. Only a single line needs to be added to the translator method to call this new method to detect exceptions:

def translator
  @numeral = self.detect_exception
  @numeral = @numeral.split('').collect do |letter|
  (...)
  end
end

Excellent. Now we just need to test that everything works:

puts "Enter roman numeral to convert to arabic numeral: "
print NumTranslator.new(gets.chomp).translator

gets.chomp #=> "MMMDXCLXIX"
return value #=> "3669"

I can hardly contain myself. I run about fifteen different number combinations to make sure that everything works. Sweet! Hopefully, I didn’t leave out any exceptions.

UPDATE: Special thanks to Ed for pointing out a problem with my nested conditions under detect_exception, and for suggesting saving on real estate by using a hashmap instead of a case statement.

Here’s the final code:

class NumTranslator

  def initialize(roman_numeral)
    @numeral = roman_numeral.upcase
  end

  def detect_exception
    if @numeral.include?("IV")
      @numeral = @numeral.split("IV").push("4").inject(:+)
      if @numeral.include?("XL")
      @numeral = @numeral.split("XL").push("F").inject(:+)
      elsif @numeral.include?("XC")
      @numeral = @numeral.split("XC").push("N").inject(:+)
      else
      @numeral
      end
    elsif @numeral.include?("IX")
      @numeral = @numeral.split("IX").push("9").inject(:+)
      if @numeral.include?("XL")
      @numeral = @numeral.split("XL").push("F").inject(:+)
      elsif @numeral.include?("XC")
      @numeral = @numeral.split("XC").push("N").inject(:+)
      else
      @numeral
      end
    elsif @numeral.include?("XL")
      @numeral = @numeral.split("XL").push("F").inject(:+)
      elsif @numeral.include?("XC")
      @numeral = @numeral.split("XC").push("N").inject(:+)
    else
      @numeral
    end
  end

  def translator
    hashmap = {"I"=>1,"4"=>4,"V"=>5,"9"=>9,"X"=>10,"F"=>40,"L"=>50,"N"=>90,"C"=>100,"D"=>500,"M"=>1000}
    @numeral = self.detect_exception
    @numeral = @numeral.split('').collect do |numeral|
      hashmap[numeral]
    end
    @numeral.inject(:+)  
  end
end
puts "Enter roman numeral to convert to arabic numeral: "
puts NumTranslator.new(gets.chomp).translator