Tom
Tom

Reputation: 1105

Randomly change of characters in a string in Ruby

I'm looking to randomly change the case of a string to upcase or downcase. There is a similar question on here for it except it doesn't guarantee that the string gets changed.

I am currently using:

def random_case_changes(string_to_change: string, change_case: 'downcase')
  raise ArgumentError, "Param is #{string_to_change.class}, not a string" unless string_to_change.is_a? String

  string_to_change.chars.map { |char| (rand 0..2).zero? ? char : char.send(change_case) }.join
end

random_case_changes(string_to_change: 'Hello', change_case: 'upcase')

There is a chance that this will just return 'Hello.

I've looked at using .sample on the array of chars, but it jumbles the order and I haven't found a way to put the string back into it's original order after a change has been made.

Is there a way to guarantee that a change will take place?

Upvotes: 1

Views: 322

Answers (4)

Tom
Tom

Reputation: 1105

Thank you all for your input. These answers pushed me down a rabbit hole of learning and investigation.

Here is the solution I went with:

def random_case_change(string, options = {})
  new_case = options[:new_case].nil? ? 'upcase' : options[:new_case]

  # don't attempt to change if string is ONLY whitespace
  unless string.match?(/\A\s*\z/)
    # only word characters will be changed,
    string.chars.each_with_index.map { |c, idx| c.match?(/\w/) ? idx : nil }.reject(&:nil?)
        .then { |indicies| indicies.sample(rand(1..indicies.size)) }
        .inject(string) { |memo, idx| memo[idx] = memo[idx].send(new_case); memo }
  end
  string
end

I took it further to ignore the string if it is only whitespace. It will also ignore any characters in the string that are not word characters. I also added a default option to the kind of I wish to use. This will allow, upcase, downcase and swapcase.

Upvotes: 0

Cary Swoveland
Cary Swoveland

Reputation: 110745

Here are three ways that could be done that ensure pseudo-randomness.

All use the string

str = "WoLf"  

for demonstration and the method

def change_possible?(str, to_case)
  str.count(to_case == :downcase ? 'a-z' : 'A-Z') < str.size       
end

to determine if the randomization procedure can produce a string that is different than the given string, str.

change_possible?("WoLf", :downcase) #=> true
change_possible?("WoLf", :upcase)   #=> true

change_possible?('wolf', :downcase) #=> false
change_possible?('WOLF', :upcase)   #=> false

The latter two approaches use the method

def char_case(c)
  c.downcase == c ? :downcase : :upcase
end

char_case('a') #=> :downcase
char_case('A') #=> :upcase

Change the case of one randomly-selected character then randomly determine whether to set the case of each of the remaining characters to the specified case1

def scramble(str, to_case)
  return nil unless change_possible?(str, to_case)
  i = str.size.times.reject do |i|
    char_case(str[i]) == to_case
  end.sample
  c = str[i].public_send(to_case)
  str.gsub(/./) { |s| [s, s.public_send(to_case)].sample }.
      tap { |s| s[i] = c }
end

str                         #=> "WoLf" 
scramble(str, :downcase)    #=> "Wolf" 
scramble(str, :downcase)    #=> "woLf" 
scramble(str, :downcase)    #=> "wolf" 
scramble("wolf", :downcase) #=> nil 

scramble(str, :upcase)      #=> "WOLF" 
scramble(str, :upcase)      #=> "WOLf"
scramble(str, :upcase)      #=> "WOLF" 
scramble("WOLF", :upcase)   #=> nil 

Randomly determine whether to set the case of each character to the specified case, repeating until the resulting string differs from the given string

def scramble(str, to_case)
  return nil unless change_possible?(str, to_case)
  loop do
    s = str.gsub(/./) { |c| [c, c.public_send(to_case)].sample }
    break s unless s == str
  end
end

str                         #=> "WoLf" 
scramble(str, :downcase)    #=> "Wolf" 
scramble(str, :downcase)    #=> "woLf"  
scramble(str, :downcase)    #=> "wolf" 
scramble("wolf", :downcase) #=> nil 

scramble(str, :upcase)      #=> "WOLF" 
scramble(str, :upcase)      #=> "WoLF" 
scramble(str, :upcase)      #=> "WoLF" 
scramble("WOLF", :upcase)   #=> nil 

Note that c.public_send(to_case) may equal c in [c, c.public_send(to_case)].sample.

Construct the sample space then select a member at random

def scramble(str, to_case)
  return nil unless change_possible?(str, to_case)
  sample_space(str, to_case).sample
end

def sample_space(str, to_case)
  fixed_idx = str.size.times.select do |i|
    char_case(str[i]) == to_case
  end
  len = str.size
  (0..2**str.size - 1).map do |n|
    len.times.with_object('') do |i,s|
      s << (n[i] == 1 ? str[i].upcase : str[i].downcase)
    end
  end.reject do |s|
    s == str || fixed_idx.any? { |i| s[i] != str[i] }
  end
end

str                         #=> "WoLf" 
scramble(str, :downcase)    #=> "wolf" 
scramble(str, :downcase)    #=> "woLf" 
scramble(str, :downcase)    #=> "Wolf"
scramble("wolf", :downcase) #=> nil 

scramble(str, :upcase)      #=> "WOLF" 
scramble(str, :upcase)      #=> "WOLF" 
scramble(str, :upcase)      #=> "WoLF"
scramble("WOLF", :upcase)   #=> nil 

For :downcase the sample space was found to be

["wolf", "Wolf", "woLf"]

For :upcase,

["WOLf", "WoLF", "WOLF"]

The sample space initially contains 2**str.size #=> 16 elements, there being a 1-1 map between each element of the sample space and one of the integers between 0 and 2**str.size - 1. The mapping corresponds to the value of the integer's bits (padded with leading zeroes, when necessary, to str.size bits), 0 corresponding to lowercase, 1 to uppercase. Invalid strings are then removed from the sample space.

See Integer#[] and note that n[i] can equal 0, 1 or nil, with nil corresponding to a leading 0.

I make no claims about the efficiency of this approach.

1. This is a variant of Stefan's approach.

Upvotes: 0

Stefan
Stefan

Reputation: 114248

Is there a way to guarantee that a change will take place?

You could get the indices of the character that could change, e.g. the lowercase ones:

string_to_change = 'Hello'

indices = string_to_change.enum_for(:scan, /[[:lower:]]/).map { $~.begin(0) }
#=> [1, 2, 3, 4]

And out of this array pick between 1 and all elements that will change: (the order doesn't matter)

indices_to_be_changed = indices.sample(rand(1..indices.size))
#=> [4, 2]

Now all you have to do is swap the case of the corresponding characters: (upper to lower or vice-versa)

indices_to_be_changed.each do |i|
  string_to_change[i] = string_to_change[i].swapcase
end

string_to_change
#=> "HeLlO"

Upvotes: 2

spickermann
spickermann

Reputation: 107077

I would do something like this:

def random_case_changes(string: string, case: 'downcase')
  if string.is_a?(String)
    string.chars.map { |char| [char, char.public_send(change_case)].sample }.join
  else
    raise ArgumentError, "Param is #{string.class}, not a string"
  end
end

Upvotes: 0

Related Questions