Search code examples
clojure

Decrease list values by ratio in Clojure


I have a little programming issue that I'm trying to resolve in Clojure.

Say, I have a list with Integer values (they also include zeros). These values have a sum, which I want to decrease by a certain value. To get to this lower sum, I want to decrease the values in the list by ratio.

Say, I have the following list: [0, 10, 30, 40, 20, 0]. The sum is 100, and I want to decrease the sum to 90. I want to decrease the values by ratio, so the new list will be [0, 9, 27, 36, 18, 0].

However, this gets problematic when the numbers turn into fractions. When you round numbers (either with round, floor or ceil), you can end up with a sum that's off by 1 or 2. I can't seem to find an elegant solution. Everything I get consists of going through all the values once, and then going back to repair the offset. Any ideas?

Edit

To clarify the behaviour I want to see, the way it rounds doesn't really matter to me, as long as the sum is correct and the ratios of the numbers are approximately the same. I don't care care whether the total error is the smallest or that most are rounded down.

Additional requirements are that numbers are only allowed to stay equal or get lower, numbers should be >= 0, and the resulting list of numbers should be integers.


Solution

  • We can specify the function's requirements with clojure.spec. If we want the function to support integers w/arbitrary precision, sequences that sum to zero, empty sequences, etc., we could write this function spec:

    (s/def ::natural-integer (s/and integer? (comp not neg?)))
    (s/fdef dec-sum-int
      :args (s/and (s/cat :new-sum ::natural-integer
                          :nums (s/coll-of ::natural-integer))
                   #(<= (:new-sum %) (apply +' (:nums %))))
      :ret  (s/coll-of ::natural-integer)
      :fn   (fn [{:keys [args ret]}]
              (and (= (count (:nums args)) (count ret))
                   ;; each output <= corresponding input
                   (every? true? (map <= ret (:nums args)))
                   (or (empty? ret)
                       (= (:new-sum args) (apply + ret))))))
    

    Then st/check the original answer below to see failing examples, or see example invocations with s/exercise-fn.

    Here's a version that satisfies the spec for your updated requirements. Most of the complexity is to ensure each output <= input when adjusting for rounding error:

    (defn dec-sum-int [new-sum nums]
      (let [sum   (apply +' nums)
            ratio (if (zero? sum) 1 (/ new-sum sum))
            nums' (map #(bigint (*' % ratio)) nums)
            err   (- new-sum (apply + nums'))]
        (loop [nums  nums
               nums' nums'
               out   []
               err   err]
          (cond
            (zero? err)
            (into out nums')
    
            (seq nums')
            (let [[num & more] nums
                  [num' & more'] nums']
              (if (pos? num)
                (let [num'' (min num (+ num' err))]
                  (recur more more'
                         (conj out num'')
                         (- err (- num'' num'))))
                (recur more more' (conj out num') err)))
    
            :else out))))
    
    (st/summarize-results (st/check `dec-sum-int))
    {:sym playground.so/dec-sum-int}
    => {:total 1, :check-passed 1}
    

    Original Answer

    Here's a function to multiply each number in a collection by a ratio to reach some desired sum:

    (defn adjust-sum [new-sum nums]
      (let [sum (apply + nums)]
        (map #(* % (/ new-sum sum))
             nums)))
    
    (adjust-sum 90 [0 10 30 40 20 0])
    => (0N 9N 27N 36N 18N 0N)
    (map int *1)
    => (0 9 27 36 18 0)
    

    For your example the results naturally come out as big integers. This is the only given example, but this problem lends itself well to property-based, generative testing. We can define properties that should hold for all examples and use test.check to test the function against many random examples we may not have imagined:

    (tc/quick-check 10000
      (prop/for-all [new-sum gen/int
                     nums (->> (gen/vector gen/int)
                               ;; current approach fails for inputs that sum to zero
                               (gen/such-that #(not (zero? (apply + %)))))]
        (= new-sum (apply + (adjust-sum new-sum nums)))))
    => {:result true, :num-tests 10000, :seed 1552170880184}
    

    See updates above for handling examples with rounding error, or prior edits for handling negative numbers.