1

I was given a list of apps along with their ratings:

let appRatings = [
    "Calendar Pro": [1, 5, 5, 4, 2, 1, 5, 4],
    "The Messenger": [5, 4, 2, 5, 4, 1, 1, 2],
    "Socialise": [2, 1, 2, 2, 1, 2, 4, 2]
]

I want to write a func that takes appRating as input and return their name and average rating, like this.

["Calendar Pro": 3,
"The Messenger": 3,
"Socialise": 2]

Does anyone know how to implement such a method that it takes (name and [rating]) as input and outputs (name and avgRating ) using a closure inside the func?

This is what I have so far.

func calculate( appName: String, ratings : [Int]) -> (String, Double ) {
    let avg = ratings.reduce(0,+)/ratings.count

    return (appName, Double(avg))
}
rmaddy
  • 314,917
  • 42
  • 532
  • 579
Jian Quan Ma
  • 37
  • 1
  • 6
  • Now that you've updated your question with your attempt, please explain what problem you are having. What help do you need? – rmaddy Sep 26 '18 at 17:32

4 Answers4

2

Fundamentally, what you're trying to achieve is a mapping between one set of values into another. Dictionary has a function for this, Dictionary.mapValues(_:), specifically for mapping values only (keeping them under the same keys).

let appRatings = [
    "Calendar Pro": [1, 5, 5, 4, 2, 1, 5, 4],
    "The Messenger": [5, 4, 2, 5, 4, 1, 1, 2],
    "Socialise": [2, 1, 2, 2, 1, 2, 4, 2]
]

let avgAppRatings = appRatings.mapValues { allRatings in
    return computeAverage(of: allRatings) // Dummy function we'll implement later
}

So now, it's a matter of figuring out how to average all the numbers in an Array. Luckily, this is very easy:

  1. We need to sum all the ratings

    • We can easily achieve this with a reduce expression. StWe'll reduce all numbers by simply adding them into the accumulator, which will start with 0

      allRatings.reduce(0, { accumulator, rating in accumulator + rate })
      

      From here, we can notice that the closure, { accumulator, rating in accumulator + rate } has type (Int, Int) -> Int, and just adds the numbers together. Well hey, that's exactly what + does! We can just use it directly:

      allRatings.reduce(0, +)
      
  2. We need to divide the ratings by the number of ratings

    • There's a catch here. In order for the average to be of any use, it can't be truncated to a mere Int. So we need both the sum and the count to be converted to Double first.
  3. You need to guard against empty arrays, whose count will be 0, resulting in Double.infinity.

Putting it all together, we get:

let appRatings = [
    "Calendar Pro": [1, 5, 5, 4, 2, 1, 5, 4],
    "The Messenger": [5, 4, 2, 5, 4, 1, 1, 2],
    "Socialise": [2, 1, 2, 2, 1, 2, 4, 2]
]

let avgAppRatings = appRatings.mapValues { allRatings in
    if allRatings.isEmpty { return nil }
    return Double(allRatings.reduce(0, +)) / Double(allRatings.count) 
}

Add in some nice printing logic:

extension Dictionary {
    var toDictionaryLiteralString: String {
        return """
        [
        \t\(self.map { k, v in "\(k): \(v)" }.joined(separator: "\n\t"))
        ]
        """
    }
}

... and boom:

print(avgAppRatings.toDictionaryLiteralString)
/* prints:
[
    Socialise: 2.0
    The Messenger: 3.0
    Calendar Pro: 3.375
]
*/

Comments on your attempt

You had some questions as to why your attempt didn't work:

func calculate( appName: String, ratings : [Int]) -> (String: Int ) {
    var avg = ratings.reduce(0,$0+$1)/ratings.count
    return appName: sum/avg
}
  1. $0+$1 isn't within a closure ({ }), as it needs to be.
  2. appName: sum/avg isn't valid Swift.
  3. The variable sum doesn't exist.
  4. avg is a var variable, even though it's never mutated. It should be a let constant.
  5. You're doing integer devision, which doesn't support decimals. You'll need to convert your sum and count into a floating point type, like Double, first.

A fixed version might look like:

func calculateAverage(of numbers: [Int]) -> Double {
    let sum = Double(ratings.reduce(0, +))
    let count = Double(numbers.count)
    return sum / count
}

To make a function that processes your whole dictionary, incoroprating my solution above, you might write a function like:

func calculateAveragesRatings(of appRatings: [String: [Int]]) -> [String: Double?] {
    return appRatings.mapValues { allRatings in
        if allRatings.isEmpty { return nil }
        return Double(allRatings.reduce(0, +)) / Double(allRatings.count) 
    }
}
Alexander
  • 59,041
  • 12
  • 98
  • 151
  • I think you want to add this line to your instead of your `return computeAverage(of: allRatings)` -> `return allRatings.reduce(0, +)/allRatings.count` Nice answer. :) – Rakesha Shastri Sep 26 '18 at 17:08
  • @RakeshaShastri yes, that is the implementation i was working on explaining. Although your example doesn't quite work, because it uses integer division. – Alexander Sep 26 '18 at 17:11
  • I removed my answer after i saw this. Yes, i did not take into account integer division, that could easily be solved by type casting though :) – Rakesha Shastri Sep 26 '18 at 17:12
  • Is it possible to write it as a func, I mainly have trouble with parameters and return type, the book I have doesn't have complicated example of parameter and return types. Also I posted my attempt on the question, can you tell me what I done wrong? – Jian Quan Ma Sep 26 '18 at 17:21
  • @JianQuanMa Updated my question. – Alexander Sep 26 '18 at 17:36
  • the return keyword there is redundant `mapValues { allRatings in Double(...` – Leo Dabus Sep 26 '18 at 18:17
  • Btw this would return a `nan` in case there is no ratings yet – Leo Dabus Sep 26 '18 at 18:54
  • @LeoDabus Good call! I didn't consider that. Fixed. Now the `return` is required. – Alexander Sep 26 '18 at 19:07
  • Your last method syntax it is also wrong. Don't you test your code before posting? **error: cannot convert value of type 'Double' to closure result type 'Int'** – Leo Dabus Sep 26 '18 at 19:08
  • `calculateAverage(of numbers: [Int]) -> Double` would still return a `nan` if an empty array is passed to the method – Leo Dabus Sep 26 '18 at 19:31
  • Note that you can avoid using optional double by using reduce(into:) instead of mapValues ----> `func calculateAveragesRatings(of appRatings: [String: [Int]]) -> [String: Double] { return appRatings.reduce(into: [:]) { if !$1.value.isEmpty { $0[$1.key] = Double($1.value.reduce(0,+)) / Double($1.value.count) } } }` – Leo Dabus Sep 26 '18 at 22:07
  • 1
    @LeoDabus Indeed. But I was not sure how OP would like to handle the `nil` case. – Alexander Sep 26 '18 at 22:14
0

This a simple solution that takes into account that a rating is an integer:

let appRatings = [
    "Calendar Pro": [1, 5, 5, 4, 2, 1, 5, 4],
    "The Messenger": [5, 4, 2, 5, 4, 1, 1, 2],
    "Socialise": [2, 1, 2, 2, 1, 2, 4, 2]
]

let appWithAverageRating: [String: Int] = appRatings.mapValues { $0.reduce(0, +) / $0.count}

print("appWithAverageRating =", appWithAverageRating)

prints appWithAverageRating = ["The Messenger": 3, "Calendar Pro": 3, "Socialise": 2]

If you'd like to check whether an app has enough ratings before returning an average rating, then the rating would be an optional Int:

let minimumNumberOfRatings = 0  // You can change this

var appWithAverageRating: [String: Int?] = appRatings.mapValues { ratingsArray in
    guard ratingsArray.count > minimumNumberOfRatings else {
        return nil
    }
    return ratingsArray.reduce(0, +) / ratingsArray.count
}

If you'd like the ratings to go by half stars (0, 0.5, 1, ..., 4.5, 5) then we could use this extension:

extension Double {
    func roundToHalf() -> Double {
        let n = 1/0.5
        let numberToRound = self * n
        return numberToRound.rounded() / n
    }
}

Then the rating will be an optional Double. Let's add an AppWithoutRatings and test our code:

let appRatings = [
    "Calendar Pro": [1, 5, 5, 4, 2, 1, 5, 4],
    "The Messenger": [5, 4, 2, 5, 4, 1, 1, 2],
    "Socialise": [2, 1, 2, 2, 1, 2, 4, 2],
    "AppWithoutRatings": []
]

let minimumNumberOfRatings = 0

var appWithAverageRating: [String: Double?] = appRatings.mapValues { ratingsArray in
    guard ratingsArray.count > minimumNumberOfRatings else {
        return nil
    }
    let rating: Double = Double(ratingsArray.reduce(0, +) / ratingsArray.count)
    return rating.roundToHalf()
}

And this prints:

appWithAverageRating = ["Calendar Pro": Optional(3.0), "Socialise": Optional(2.0), "The Messenger": Optional(3.0), "AppWithoutRatings": nil]
ielyamani
  • 17,807
  • 10
  • 55
  • 90
  • You shouldn't use such an iterative approach, when `mapValues` is made *exactly* for use cases like this. – Alexander Sep 26 '18 at 17:30
  • @Sulthan It depends, It could be an integer number of stars from zero to 5, or it may go by halves (0, 0.5, .., 4.5, 5). It depends on the OP's needs. – ielyamani Sep 26 '18 at 17:41
  • @Carpsen90 True, but with integer arithmetic the average of `[1, 2]` is `1`, which is simply incorrect. – Sulthan Sep 26 '18 at 17:46
  • Of course, it's a design choice, I'll add an option to have ratings go by halves. – ielyamani Sep 26 '18 at 17:48
  • @LeoDabus didn't `guard ratingsArray.count > minimumNumberOfRatings` address that? – ielyamani Sep 26 '18 at 18:58
  • @Carpsen90 yes but OP asked `Int` type not `Int?` or `Double?` Just `return 0` – Leo Dabus Sep 26 '18 at 19:00
  • @LeoDabus Yes did mention in the answer, that `nil` could represent an entry without ratings. I didn't want to suggest special values like `-1` – ielyamani Sep 26 '18 at 19:03
  • In my mind a struct Rating would be preferable. Since it may have special behaviour (like only going by halves) – ielyamani Sep 26 '18 at 19:04
0

I decided to make an Dictionary extension for this, so it is very easy to use in the future.

Here is my code I created:

extension Dictionary where Key == String, Value == [Float] {
    func averageRatings() -> [String : Float] {
        // Calculate average
        func average(ratings: [Float]) -> Float {
            return ratings.reduce(0, +) / Float(ratings.count)
        }

        // Go through every item in the ratings dictionary
        return self.mapValues { $0.isEmpty ? 0 : average(ratings: $0) }
    }
}



let appRatings: [String : [Float]] = ["Calendar Pro": [1, 5, 5, 4, 2, 1, 5, 4],
                                      "The Messenger": [5, 4, 2, 5, 4, 1, 1, 2],
                                      "Socialise": [2, 1, 2, 2, 1, 2, 4, 2]]

print(appRatings.averageRatings())

which will print the result of ["Calendar Pro": 3.375, "Socialise": 2.0, "The Messenger": 3.0].

George
  • 25,988
  • 10
  • 79
  • 133
0

Just to make the post complete another approach using reduce(into:) to avoid using a dictionary with an optional value type:

extension Dictionary where Key == String, Value: Collection, Value.Element: BinaryInteger {
    var averageRatings: [String : Value.Element] {
        return reduce(into: [:]) {
            if !$1.value.isEmpty {
                $0[$1.key] = $1.value.reduce(0,+) / Value.Element($1.value.count)
            }
        }
    }
}

let appRatings2 = ["Calendar Pro" : [1, 5, 5, 4, 2, 1, 5, 4],
                   "The Messenger": [5, 4, 2, 5, 4, 1, 1, 2],
                   "Socialise"    : [2, 1, 2, 2, 1, 2, 4, 2] ]
let keySorted = appRatings2.averageRatings.sorted(by: {$0.key<$1.key})
keySorted.map{ print($0,$1) }

Calendar Pro 3

Socialise 2

The Messenger 3

Leo Dabus
  • 229,809
  • 59
  • 489
  • 571