3

I'm new to Python and want to learn the most optimized ways to code. I'm doing the classic intro problem of assigning letter grades to numerical grade; I already understand the simple way of chaining a bunch of elif, but is there a more optimized way out there? What if say I have a lot more sub-grades, or if later I need to change the criteria for each letter grade? Is there a way to create something similar to a dict for a range of values?

This is the problem in its whole:

Write a function named letter_grade that takes an integer argument, which represents a mark of a student. Return ‘A’ if mark ≥ 90, ‘B’ if 80 ≤ mark < 90, ‘C’ if 70 ≤ mark < 80, ‘D’ if 60 ≤ mark < 70, and ‘E’ if mark < 60. Return None if it is not a valid mark. A valid mark ranges from 0 to 100.

So the basic brute force method we were taught is simply:

def letter_grade(mark):
    if mark >100:
        grade = None
    elif mark >=90:
        grade = 'A'
    elif mark >= 80:
        grade = 'B'
    elif mark >= 70:
        grade = 'C'
    elif mark >= 60:
        grade = 'D'
    elif mark >= 0:
        grade = 'E'
    else:
        grade = None
    return grade

But now if let's say I have a lot more sub grades, like A+, A, A- etc all the way until F. I don't want to chain 20+ elif together for that. I want to know if there's a shorter way to handle this problem.

John Kugelman
  • 349,597
  • 67
  • 533
  • 578
Dan
  • 195
  • 3
  • 12
  • What you are looking for is *binary search*; starting with a sorted sequence of N boundaries you can select a matching range in O(log N) time. So for 1000 elements, that’s < 10 steps. `if / elif / else` or a switch statement or any of the iterative approaches below find a matching range in O(N) linear time, so up to 1000 steps for 1000 elements. – Martijn Pieters Feb 03 '19 at 17:54
  • If your range sizes are fixed, then there is a closed form solution (== use maths), and just divide by the range size to give you an index into the letter mapping, as two answers below cover. – Martijn Pieters Feb 03 '19 at 18:00

4 Answers4

2

You could use next with a condition on a list of tuples representing the grade-cutoff-scores:

grades = ((100, None), (90, 'A'), (80, 'B'), (70, 'C'), (60, 'D'), (0, 'E'))
def get_grade(mark):
    return next((grade for score, grade in grades if mark >= score), None)

>>> get_grade(15)
'E'
>>> get_grade(75)
'C'
>>> get_grade(95)
'A'

The (100, None) and the default None are for scores greater than 100 or smaller than 0.

Or not quite as short, but IMHO better, with a range-check and raising a proper exception:

grades = ((90, 'A'), (80, 'B'), (70, 'C'), (60, 'D'), (0, 'E'))
def get_grade(mark):
    if 0 <= mark <= 100:
        return next(grade for score, grade in grades if mark >= score)
    raise ValueError("Mark must be between 0 and 100")

While this will loop over all the possible grades until it finds the right one, given that the number of grades is very low and constant, this can still be considered O(1). If the number of grades/intervals is much higher, you might consider using bisect to binary-search the right interval, as can be seen in some of the now-linked answers, but that's a bit less untuitive a easy to get nasty off-by-one errors.

tobias_k
  • 81,265
  • 12
  • 120
  • 179
1

Create a dict mapping letter and minimum score. Then loop through the possibilities in order and check if the score matches.

def get_grade(score):
    GRADES = {None: 100, 'A': 90, 'B': 80, 'C': 50, 'D': 20, 'F': 0}
    for grade, theshold in GRADES.items():
        if score > threshold:
            return grade
    return None

Note this relies on ordered dictionaries, a feature in 3.7. Before that, collections.OrderedDict is available. Alternatively, you can use two iterables, one for grade and one for score. You can zip(('grades here', None, 'A', 'B'), ('scores here', 100, 90, 80)) them to get a loop as above or directly create one in the form (('grade', 'score'), (None, 100), ('A', 90), ...).

John Kugelman
  • 349,597
  • 67
  • 533
  • 578
user24343
  • 892
  • 10
  • 19
  • @user24343 Your range numbers are wrong if the score is `30` it returns `D` , it must be `F`. – Raydel Miranda Feb 03 '19 at 17:14
  • Dictionaries are unordered structures in Python 3.5 and older, where your version will fail to find a matching range. Besides, If you do have a sorted sequence, then binary search is going to win, hands-down. – Martijn Pieters Feb 03 '19 at 17:56
  • @MartijnPieters I explicitly mentioned "feature in 3.7" (implementation detail in CPython 3.6), ``OrderedDict``, and the possibility of using iterables. If you find it not evident, you're welcome to add emphasis – user24343 Feb 03 '19 at 18:17
  • @RaydelMiranda the numbers used are purely examples. The question actually mentioned using sub-grades, and I hope no one just copy-pastes code without reading it carefully -- especially if this is homework – user24343 Feb 03 '19 at 18:20
1

A shorter solution would be something along the lines of the following code

def letter_grade(mark):
    if mark > 100 or mark <= 0:
        return None
    return chr(ord('E') - max(((mark - 50) // 10), 0))

What this works for every case (!), it's pretty tricky to follow. In particular, what I've done is computed the cutoff for 'mark' to the nearest multiple of 10, subtracted 50, and minimized that result at 0 (for instance, max(((75 - 50) // 10), 0) = max (25 // 10, 0) = max(2, 0) = 2). I then subtract this number from the integer representation of the letter 'E' and then send the result back to a character. Since, for instance, 'C' is two away from 'E', this produces the correct letter grade by going "that far down".

HOWEVER. Look how long it took me to explain this solution (and it's tricky to understand!) and compare that to how easy it is to understand your solution. I bet you there's an even more dense and 'short' solution in one line out there as well, but it's even harder to understand! The point is that shorter code is not necessarily more readable, and the solution you proposed is both clear and easy to follow, and would be what I would use MOST OF THE TIME :)

Checkmate
  • 1,074
  • 9
  • 16
  • Well, that is O(1), but strictly speaking so is any other solution given the limited (and very small) number of grades. And this solution is very easy to mess up, hard to read, harder to generalize, and does not work for sub-grades like B+ – tobias_k Feb 03 '19 at 16:37
  • @tobias_k agreed, your solution is better. The point of this answer was mostly to illustrate that shorter solutions aren't necessarily 'better' in any sense, and can make the code harder to read – Checkmate Feb 03 '19 at 16:43
0

The 10 value ranges from 0 to 100 can be expressed by a single integer as the result of score / 10, this is O(1) complexity since not require interaction.

Using defaultdict:

from collections import defaultdict


lowest_grade = lambda: "E"
grades = defaultdict(lowest_grade)

grades.update({
    10: "A", 9: "A", 8: "B", 7: "C", 6: "D", # Lower values get "E" by default.
})

def letter_grade(score):
    # If the condition to not match function will return None.
    if 0 <= score <= 100:
        return grades[int(score / 10)]

if __name__ == "__main__":
    print(letter_grade(100))   # A
    print(letter_grade(85))    # B
    print(letter_grade(65))    # D
    print(letter_grade(43))    # E

Another possible answer ...

... having in to account all comments about performance, clarity, and extensibility this is another way you could tackle your problem with maximum extensibility and performance (speed).

Since all values can have a score are just a few (0-100), you can use memoization to store the corresponding letter-grade for each score value:

conf = (
    (0, 59, "E"),
    (60, 69, "D"),
    (70, 75, "C"),
    (76, 79, "C+"),
    (80, 85, "B"),
    (86, 89, "B+"),
    (90, 95, "A"),
    (96, 100, "A+"),
)

grade_memoization = [None] * 101

# This for loop is executed only once, just for setting
# "memoized" grades in grade_memoization.
for cfg in conf:
    for i in range(cfg[0], cfg[1] + 1):
        grade_memoization[i] = cfg[2]

# Then you can access letter-grade values on O(1), every time, simple, no 
# iterators, generators or for loops for each one.

# And you can extend the this as much as you want easily.

print(grade_memoization[100])   # A
print(grade_memoization[95])    # A+
print(grade_memoization[89])    # B+
print(grade_memoization[71])    # C
print(grade_memoization[76])    # C+
Raydel Miranda
  • 13,825
  • 3
  • 38
  • 60
  • This works only for score thresholds divisible by ten, needs updating when they change (divide by a different number) and requires the one inserting them to divide them by 10 now, maybe something ore complicated later. – user24343 Feb 03 '19 at 17:06
  • @user24343 This work for the OP's problem, he is solving a very particular requirement, he is tackling a **classical intro problem** the requirement won't change. – Raydel Miranda Feb 03 '19 at 17:11
  • OP explicitly talked about a possible extension with sub-grades etc. How would your approach work for those? Yes, it solves OP's particular problem, but is hard to generalize, und the "no iteration" aspect is not really relevant for the small and constant number of grades. – tobias_k Feb 03 '19 at 20:41
  • @tobias_k the no iteration aspect it is very relevant, a function like this is intended to be used once per score. And might be hundreds of those. And while it is true this is no a direct requirement, we must educate the new Python programmers in the Python zen. My answer is that, clare, and optimal, and it can be extended to support subgrades. Just use float and math.floor, can you see it? ;). Is no so hard. – Raydel Miranda Feb 03 '19 at 23:50
  • @tobias_k I just add another section to my answer, since all possible scores are just a few (0-100) you could "memoize" them. That way the solution still O(1) when getting the letter-grade version of the score and it is as extensible as you want. – Raydel Miranda Feb 04 '19 at 00:49
  • What I meant is: In OP's original question, there are only k=5 grades, and even if we include sub-grades, the number of grades is unlikely to exceed, say, k=20. So all the solutions that use a loop over the grades are O(k), and since k is a constant, they are all O(1), too, and _much_ simpler and easier to extend. (Yes, you included some subgrades, but what if differences between grades are unevenly spaced? maybe that's possible, too, but that code will be far from "pythonic") – tobias_k Feb 04 '19 at 09:48
  • Also, your second approach may work for integer scores from 0 to 100, but gets much more expensive it it would allow scores from 0 to 1,000,000, and infeasible if there are fractional scores. (And, yes, such high scores in a test may not be realistic, but you might want to use that code for other purposes, too, e.g. determine tax classes, or character levels based on XP for a CRPG game. – tobias_k Feb 04 '19 at 09:49
  • @tobias_k You're totally right about `from 1 to 1e6` range scores but I have to say a range like that would make any answer here impractical (even yours, which is a good one by the way) depending on how many partitions you want to represent. And about fractionals scores, many people will see that as a completely different problem, including me, of course. After reading about the subject and visiting links posted by @MartijnPieters I think we should agree that `bisect` module is the way to go, since more intuitive solution not always is the best. – Raydel Miranda Feb 05 '19 at 04:13
  • It'd have been a pleasure having this friendly argue with you, folks like you make this site more interesting, see you around. – Raydel Miranda Feb 05 '19 at 04:13