Skip to content

My shop’s total says 0.30000000000000004

  • Python · Foundations
  • 20 min read, about 1 h 15 min with practice
  • Concept
  • Asked often: medium
  • Reviewed 2026-11

New to Python? Open the quick recap. It takes about two minutes.

Quick recapNames and print() in two minutes· 2 min

A name holds a value: price = 2.5 makes the name price for the number 2.5. Text goes in quotes, and print() shows values with a space between them.

price = 2.5
name = "Tea"
print(name, price) # Tea 2.5

Your shop’s checkout adds up the prices in an order. The price file stores them as text, like "0.10":

prices = ["0.10", "0.20"]
total = sum(float(p) for p in prices)
print("Total:", total) # Total: 0.30000000000000004

You show it with two decimals, f"{total:.2f}", and it says 0.30. Fixed?

Then a customer with a 10% coupon buys a 0.70 drink and a 0.45 snack. That’s 1.15, less 10%: exactly 1.035. Your shop rounds half a cent up, so the right charge is 1.04. The checkout charges 1.03. print(total) even shows 1.035. So why does it round down?

Write order_total(prices, percent_off).

  • Input: prices, the prices in the order as text with two decimals, like "0.70". There’s at least one. And percent_off, a whole number from 0 to 100.
  • Output: the total, less percent_off percent, rounded to the cent, as text with two decimals, like "1.04".
  • The hard part: half a cent rounds up. 1.035 is "1.04", and 1.125 is "1.13".

Example: order_total(["0.70", "0.45"], 10) gives "1.04".

Hints open one at a time:

Hint 1 of 3

A float keeps numbers in binary. Most decimals, like 0.1 or 1.15, have no exact binary form, so Python keeps the nearest one.

Hint 2 of 3

Decimal("1.15") from the decimal module keeps decimal digits exactly. Make it from the text, not from a float.

Hint 3 of 3

round() sends a half to the even neighbour: round(Decimal("1.125"), 2) is 1.12. quantize() lets you choose ROUND_HALF_UP.

Whole numbers are exact, at any size. An int has no top: 2 ** 100 is 1267650600228229401496703205376. 7 // 2 is 3 (divide and round down), and 7 % 2 is 1 (what’s left). 7 / 2 is 3.5: / always gives a float, even 4 / 2, which is 2.0. And // rounds down, toward minus infinity, so -7 // 2 is -4, not -3.

A float is the nearest binary fraction. In decimal, 1/3 never ends: 0.333… In binary, 1/10 never ends either. A float keeps about 17 significant digits, so Python stores the float nearest to 0.1:

from decimal import Decimal
print(Decimal(0.1)) # 0.1000000000000000055511151231257827021181583404541015625
print(0.1 + 0.2) # 0.30000000000000004
print(0.1 + 0.2 == 0.3) # False

print(0.1) shows 0.1, because Python prints the shortest text that gives back the same float. The tiny error is still there.

That’s the coupon bug. 1.15 is stored as 1.14999999999999991…, so 1.15 less 10% comes out a hair under 1.035. print() shows 1.035, the shortest text for that float. But :.2f sees a number under the half, and rounds it down to 1.03.

Decimal keeps decimal digits. Decimal("1.15") is exactly 1.15, and Decimal("1.15") * 90 / 100 is exactly 1.035. Make it from the text: Decimal(1.15) starts from the float, error and all.

round() sends a half to the even neighbour. round(2.5) is 2, and round(3.5) is 4. round(Decimal("1.125"), 2) is 1.12. This is called banker’s rounding: over many sums, the halves that go up and the halves that go down even out. When a rule says half up, say so:

from decimal import ROUND_HALF_UP, Decimal
Decimal("1.125").quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) # Decimal('1.13')

A string is a row of characters that can’t change. With s = "Python", s[0] is "P", and s[-1] is "n": a negative place counts from the back. s[0] = "J" raises TypeError: 'str' object does not support item assignment. Methods like upper(), strip() and replace() give back a new string and leave s as it was, so keep what they give back: name = name.strip().

A slice cuts out a piece: s[start:stop:step]. It starts at start, moves step places at a time, and stops before stop. Leave a part out and it means the end. Watch a slice take its places, then try your own text and slice, like -3: or ::-1.

Where a slice cuts
  1. P0-6
  2. y1-5
  3. t2-4
  4. h3-3
  5. o4-2
  6. n5-1

Step 1 of 7. s holds 6 characters. Each has a place counted from the front, 0 to 5, and one counted from the back, -6 to -1, shown under it.

s = "Python"
print(s[1:4])

Try your own input

An f-string puts values into text. f"{name} costs {price}" puts in the values of name and price. After a colon, say how to show a value:

You writeYou getWhich means
f"{2.5:.2f}"2.502 decimals
f"{2.5:8.2f}" 2.50at least 8 wide, on the right
f"{'Tea':<8}"Tea at least 8 wide, on the left
f"{1234567:,}"1,234,567commas between thousands
f"{0.256:.1%}"25.6%a percent, 1 decimal
f"{7:03d}"0073 wide, with zeros in front
f"{price=}"price=2.5the name and the value, useful for debugging

All three give "0.30" for order_total(["0.10", "0.20"], 0). What changes is what they do with a half cent.

Okay

Floats, shown with two decimals

The idea: Add the prices as floats, take the percent off, and show it with :.2f.

It fixes the long tail: 0.1 + 0.2 shows as 0.30.

def order_total(prices: list[str], percent_off: int) -> str:
total = sum(float(p) for p in prices) * (100 - percent_off) / 100
return f"{total:.2f}"
Time
O(n)
Space
O(1)
Why
One pass over the n prices.

Where it breaks: 0.70 and 0.45 less 10% is 1.035, which should be "1.04". The float is a hair under 1.035, so it gives "1.03". A customer is charged the wrong amount, and print() hides why.

Better

Decimal from the text, then round()

The idea: Make each price a Decimal from its text, so 1.035 is exact. Then round(total, 2).

The waste in Okay: it rounds a number that’s already a little wrong.

Decimal("0.70") + Decimal("0.45") is exactly 1.15, and 1.15 less 10% is exactly 1.035. round() then gives 1.04.

Highlighted lines are new in this rung.

from decimal import Decimal
def order_total(prices: list[str], percent_off: int) -> str:
total = sum(Decimal(p) for p in prices) * (100 - percent_off) / 100
return str(round(total, 2))
Time
O(n)
Space
O(1)
Why
One pass over the n prices.
What changed from the previous rung
from decimal import Decimal
def order_total(prices: list[str], percent_off: int) -> str:
total = sum(float(p) for p in prices) * (100 - percent_off) / 100
return f"{total:.2f}"
total = sum(Decimal(p) for p in prices) * (100 - percent_off) / 100
return str(round(total, 2))

Where it breaks: round() sends a half to the even neighbour. 1.25 less 10% is exactly 1.125, and round() gives "1.12", where the shop’s rule says "1.13". Half a cent off a 1-cent item gives "0.00".

Best

Decimal, rounded half up

The idea: The same exact total, then quantize(Decimal('0.01'), rounding=ROUND_HALF_UP).

The waste in Better: it works out the exact total, then rounds it by someone else’s rule.

quantize(CENT) rounds to two decimals, and rounding=ROUND_HALF_UP says what to do with a half. The result always has two decimals, even 0.00.

Highlighted lines are new in this rung.

from decimal import ROUND_HALF_UP, Decimal
CENT = Decimal("0.01")
def order_total(prices: list[str], percent_off: int) -> str:
total = sum(Decimal(p) for p in prices) * (100 - percent_off) / 100
return str(total.quantize(CENT, rounding=ROUND_HALF_UP))
Time
O(n)
Space
O(1)
Why
One pass over the n prices.
What changed from the previous rung
from decimal import Decimal
from decimal import ROUND_HALF_UP, Decimal
CENT = Decimal("0.01")
def order_total(prices: list[str], percent_off: int) -> str:
total = sum(Decimal(p) for p in prices) * (100 - percent_off) / 100
return str(round(total, 2))
return str(total.quantize(CENT, rounding=ROUND_HALF_UP))

When Best isn’t best: many shops keep money as whole cents in an int, which is exact and fast: 1.15 is 115. And when the law or a bank sets the rule, check it first. Some say half to even, and some round each line, not the total.

Now write it in the page. Press Run tests to check your code. Python runs in your browser, so nothing is sent anywhere. Your code is saved in this browser for next time.

Try Okay from the ladder first: it gets three of the half cents wrong.

Total an order to the cent

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: order_total(['0.10', '0.20'], 0) → '0.30'
  2. Not run yet: order_total(['19.99', '5.00'], 0) → '24.99'
  3. Not run yet: order_total(['0.70', '0.45'], 10) → '1.04'
  4. Not run yet: order_total(['2.55'], 10) → '2.30'
  5. Not run yet: order_total(['0.50', '0.75'], 10) → '1.13'
  6. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
WayTimeExtra memoryGoodNot so goodUse it when
Okay: floats, shown with two decimalsO(n)O(1)Short, and fine for showRounds some half cents the wrong wayMeasurements, not money
Better: Decimal, then round()O(n)O(1)Exact totalHalf to even, not half upYour rule is half to even
Best: Decimal, rounded half upO(n)O(1)Exact, and rounds by your ruleA few more names to importMoney, almost always
  • round(2.675, 2) is 2.67. The float is a hair under 2.675, so even round() can’t help it.
  • Text and numbers don’t mix on their own. "Total: " + 3 raises TypeError: can only concatenate str (not "int") to str. Use str(3), or an f-string. And "ab" * 3 is "ababab", not an error.
  • int("19.99") raises ValueError. int() takes whole numbers only. Use Decimal("19.99"), or float() when a float is fine.
  • Text compares character by character. sorted(["9.99", "10.00"]) gives ["10.00", "9.99"], since "1" comes before "9". Sort prices with key=Decimal.
  • A width in an f-string is the least, not the most. f"{'Earl Grey':<5}" is all 9 characters. Cut it first with a slice: f"{name[:5]:<5}".
  • int() refuses very long text. int("1" * 5000) raises ValueError: Exceeds the limit (4300 digits) for integer string conversion: value has 5000 digits; use sys.set_int_max_str_digits() to increase the limit. That limit keeps a huge number from tying up a server.
  • Building a long string by adding to the front is slow. result = digit + result copies all of result each time. Collect the pieces in a list, then "".join(pieces).

Questions to ask first

  • How should a half cent round: half up, or half to even? Per line, or on the total?
  • Do the prices come in as text, floats or whole cents?
  • How big can the numbers get, and how many items in an order?

A 60-second walkthrough

“Floats are binary fractions, so 0.70 and 0.45 aren’t stored exactly, and 1.15 less 10% comes out a hair under 1.035. Formatting it with two decimals then rounds down. I’d make each price a Decimal from its text, so the total is exact: 1.035. Then I’d round with quantize to two places and ROUND_HALF_UP, since round() sends halves to the even neighbour. It’s one pass, O(n), and the answer always has two decimals.”

Likely follow-ups

  • Why not just round(total, 2) on the float? The float is already a hair under the half, so it rounds the wrong way too.
  • Why not whole cents? That works well: add int cents, and do the percent with // and the rounding rule by hand. Decimal says the rule in one word.
  • What does 0.1 + 0.2 == 0.3 give, and how do you compare floats? False. Use math.isclose().
  • How do you reverse a string? s[::-1]: a slice with a step of -1.

Four problems, from easy to hard. Each has hints, tests you can run here, and its own Okay, Better and Best answers. Try before you open them.

Write same_backwards(text): True if text reads the same backwards, counting only letters and digits, and ignoring upper and lower case. So "Step on no pets!" is True. The text can be long, so use as little extra memory as you can.

Hint 1 of 3

text == text[::-1] checks every character, spaces, capitals and ! included.

Hint 2 of 3

Keep only the letters and digits, in lower case, then compare with the reverse. But that builds a copy of the text, and its reverse.

Hint 3 of 3

Compare from both ends at once: i from the front and j from the back. Skip what isn’t a letter or digit, and stop when they meet.

Same backwards

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: same_backwards('racecar') → True
  2. Not run yet: same_backwards('Never odd or even') → True
  3. Not run yet: same_backwards('Step on no pets!') → True
  4. Not run yet: same_backwards('Python') → False
  5. Not run yet: Extra test
  6. Not run yet: A text of 200,000 characters, in 1 MB → True

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Compare with the reverse

The idea: text == text[::-1]

One line, and right for a single word in lower case.

def same_backwards(text: str) -> bool:
return text == text[::-1]
Time
O(n)
Space
O(n)
Why
The reverse is a copy of all n characters.

Where it breaks: it counts the spaces, the capitals and the !. "Never odd or even" backwards is "neve ro ddo reveN".

Better

Clean it, then compare

The idea: Keep the letters and digits in lower case, then compare that with its reverse.

The waste in Okay: it compares characters the problem says to skip.

c.isalnum() keeps letters and digits, and c.lower() makes "N" and "n" the same.

Highlighted lines are new in this rung.

def same_backwards(text: str) -> bool:
kept = "".join(c.lower() for c in text if c.isalnum())
return kept == kept[::-1]
Time
O(n)
Space
O(n)
Why
One pass to clean, one to reverse; the clean copy, its reverse, and the list join builds first.
What changed from the previous rung
def same_backwards(text: str) -> bool:
return text == text[::-1]
kept = "".join(c.lower() for c in text if c.isalnum())
return kept == kept[::-1]

Where it breaks: "".join() first puts all the characters in a list, then builds the copy, then the reverse. For 200,000 characters it held about 5 MB, when the answer needs only two places in the text.

Best

Two places, moving in

The idea: i from the front, j from the back. Skip what isn’t a letter or digit; if the two differ, it’s False.

The waste in Better: it copies the whole text twice, to read it once.

The text is already there to read from both ends. The loop stops when i and j meet in the middle.

Highlighted lines are new in this rung.

def same_backwards(text: str) -> bool:
i, j = 0, len(text) - 1
while i < j:
if not text[i].isalnum():
i += 1
elif not text[j].isalnum():
j -= 1
elif text[i].lower() != text[j].lower():
return False
else:
i += 1
j -= 1
return True
Time
O(n)
Space
O(1)
Why
Each character is looked at once, by i or by j; only two places are kept.
What changed from the previous rung
def same_backwards(text: str) -> bool:
kept = "".join(c.lower() for c in text if c.isalnum())
return kept == kept[::-1]
i, j = 0, len(text) - 1
while i < j:
if not text[i].isalnum():
i += 1
elif not text[j].isalnum():
j -= 1
elif text[i].lower() != text[j].lower():
return False
else:
i += 1
j -= 1
return True

When Best isn’t best: for short text, Better is clearer, and often faster in Python, since join() and the slice run in C. Pick Best when the text is big, or memory is tight.

Write reverse_words(sentence): the words of sentence in reverse order, with one space between them and none at the ends. In sentence, words are split by spaces or tabs, maybe several in a row, and there may be some at the ends.

Example: reverse_words(" hello world ") gives "world hello".

Hint 1 of 3

split(" ") splits at every single space, so two spaces in a row leave an empty word between them.

Hint 2 of 3

split() with nothing in the brackets splits at any run of spaces or tabs, and drops the ends. Then put the words together, last first.

Hint 3 of 3

Adding each word to the front of the answer copies the answer every time. Reverse the list of words, then join them once.

Reverse the words

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: reverse_words('the sky is blue') → 'blue is sky the'
  2. Not run yet: reverse_words(' hello world ') → 'world hello'
  3. Not run yet: reverse_words('a good example') → 'example good a'
  4. Not run yet: reverse_words('one') → 'one'
  5. Not run yet: Extra test
  6. Not run yet: Speed test: 300,000 words

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Split at each space, reverse, join

The idea: " ".join(sentence.split(" ")[::-1])

One line, and right when each word has exactly one space after it.

def reverse_words(sentence: str) -> str:
return " ".join(sentence.split(" ")[::-1])
Time
O(n)
Space
O(n)
Why
One pass to split, one to reverse the list, one to join.

Where it breaks: " hello world ".split(" ") is ["", "", "hello", "world", "", ""]. The empty words keep the extra spaces, so the answer is " world hello ". A tab isn’t split at all.

Better

split(), then each word in front

The idea: split() with no argument, then add each word to the front of the answer.

The waste in Okay: it treats each single space as a split, so runs of spaces make empty words.

split() splits at any run of spaces or tabs and ignores them at the ends, so every answer is right.

Highlighted lines are new in this rung.

def reverse_words(sentence: str) -> str:
result = ""
for word in sentence.split():
result = word + " " + result # put each word in front
return result.strip()
Time
O(n²)
Space
O(n)
Why
A string can’t change, so each word + answer copies the whole answer so far.
What changed from the previous rung
def reverse_words(sentence: str) -> str:
return " ".join(sentence.split(" ")[::-1])
result = ""
for word in sentence.split():
result = word + " " + result # put each word in front
return result.strip()

Where it breaks: word + " " + result builds a new string with everything so far in it. For 300,000 words, that’s copying billions of characters: too slow.

Best

split(), reversed(), join() once

The idea: " ".join(reversed(sentence.split()))

The waste in Better: it copies the answer again for every word.

reversed() walks the list from the back without copying it, and join() builds the answer in one go.

Highlighted lines are new in this rung.

def reverse_words(sentence: str) -> str:
return " ".join(reversed(sentence.split()))
Time
O(n)
Space
O(n)
Why
join() works out the size, then copies each word once.
What changed from the previous rung
def reverse_words(sentence: str) -> str:
result = ""
for word in sentence.split():
result = word + " " + result # put each word in front
return result.strip()
return " ".join(reversed(sentence.split()))

When Best isn’t best: in a language whose strings can change, like C, interviewers often want the words reversed in place: reverse the whole text, then each word. In Python, a string can’t change, so this is the way.

Write top_words(text, k): the k most common words in text, most common first. A word is a run of the letters a to z, in any case: "Blue." and "blue" are the same word. When two words are equally common, the one first in alphabetical order comes first. If there are fewer than k different words, give them all.

Example: top_words("the cat and the hat", 1) gives ["the"].

Hint 1 of 3

split() keeps the punctuation: "blue." and "blue," come out as two different words.

Hint 2 of 3

A regular expression can pick out just the runs of letters: look at re.findall. Then collections.Counter counts them all in one pass.

Hint 3 of 3

Counter.most_common() keeps equal counts in the order the words first came. Sort with the key (-count, word) instead.

The most common words

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: top_words('the cat and the hat', 1) → ['the']
  2. Not run yet: top_words('Blue. blue, BLUE! red', 2) → ['blue', 'red']
  3. Not run yet: top_words('b a b a c', 2) → ['a', 'b']
  4. Not run yet: top_words('one', 5) → ['one']
  5. Not run yet: Extra test
  6. Not run yet: Speed test: 200,120 words, 20,003 of them different → ['zebra', 'yak', 'xenon']

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Count each word with count()

The idea: Split the lower-case text, then sort the different words by (-words.count(w), w).

It sorts ties the right way, and it’s short.

def top_words(text: str, k: int) -> list[str]:
words = text.lower().split()
return sorted(set(words), key=lambda w: (-words.count(w), w))[:k]
Time
O(n · u)
Space
O(n)
Why
count() looks through all n words, once for each of the u different words.

Where it breaks: "blue.", "blue," and "blue!" are three different words to split(). And count() reads the whole list again for each different word: with about 200,000 words and 20,003 different ones, that’s 4 billion looks, and it runs out of time.

Better

Find the words, and count them once

The idea: Find the runs of letters with re.findall, count them with a Counter, then most_common(k).

The waste in Okay: it counts the same list again and again.

re.findall(r"[a-z]+", ...) keeps only runs of letters, so the punctuation is gone. Counter counts every word in one pass, with a dict.

Highlighted lines are new in this rung.

import re
from collections import Counter
def top_words(text: str, k: int) -> list[str]:
counts = Counter(re.findall(r"[a-z]+", text.lower()))
return [word for word, _ in counts.most_common(k)]
Time
O(n + u log u)
Space
O(n)
Why
One pass to find and count the words; most_common() sorts the u counts.
What changed from the previous rung
import re
from collections import Counter
def top_words(text: str, k: int) -> list[str]:
words = text.lower().split()
return sorted(set(words), key=lambda w: (-words.count(w), w))[:k]
counts = Counter(re.findall(r"[a-z]+", text.lower()))
return [word for word, _ in counts.most_common(k)]

Where it breaks: most_common() keeps equal counts in the order they first came. "b a b a c" gives ["b", "a"], not ["a", "b"].

Best

Count once, sort by count, then word

The idea: The same Counter, then sorted(counts, key=lambda w: (-counts[w], w))[:k].

The waste in Better: it lets most_common() choose the order of the ties.

The key (-count, word) sorts by count, biggest first, and then by the word itself, a to z.

Highlighted lines are new in this rung.

import re
from collections import Counter
def top_words(text: str, k: int) -> list[str]:
counts = Counter(re.findall(r"[a-z]+", text.lower()))
return sorted(counts, key=lambda w: (-counts[w], w))[:k]
Time
O(n + u log u)
Space
O(n)
Why
One pass to count; then a sort of the u different words.
What changed from the previous rung
import re
from collections import Counter
def top_words(text: str, k: int) -> list[str]:
counts = Counter(re.findall(r"[a-z]+", text.lower()))
return [word for word, _ in counts.most_common(k)]
return sorted(counts, key=lambda w: (-counts[w], w))[:k]

When Best isn’t best: with millions of different words and a small k, heapq.nsmallest(k, counts, key=...) skips sorting them all: O(u log k).

Write add_as_text(a, b): a and b are whole numbers, 0 or more, written as text, up to a million digits each. Give back their sum, as text. Neither has extra zeros in front.

Example: add_as_text("999", "1") gives "1000".

Hint 1 of 3

int(a) + int(b) is the obvious way. Try it on a number with 5,000 digits.

Hint 2 of 3

Add digit by digit, from the right, the way you learned at school: each column gives a digit and maybe a carry of 1.

Hint 3 of 3

result = digit + result copies the whole result for every digit. Append the digits to a list, then join them once, reversed.

Add two numbers written as text

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: add_as_text('19', '5') → '24'
  2. Not run yet: add_as_text('999', '1') → '1000'
  3. Not run yet: add_as_text('0', '0') → '0'
  4. Not run yet: Extra test
  5. Not run yet: A number with 5,000 nines, plus 1
  6. Not run yet: Speed test: two numbers of a million digits (how many digits, the first 3, the last 3) → [1000001, '133', '332']

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Turn them into ints

The idea: str(int(a) + int(b))

Python’s int has no top, so this is right, and it reads well.

def add_as_text(a: str, b: str) -> str:
return str(int(a) + int(b))
Time
O(n²)
Space
O(n)
Why
Turning n digits of text into an int, and back, takes time that grows faster than n.

Where it breaks: int() refuses text of more than 4,300 digits: ValueError: Exceeds the limit (4300 digits) for integer string conversion: value has 5000 digits; use sys.set_int_max_str_digits() to increase the limit. Python stops there on purpose, because turning a huge number into text and back is slow.

Better

Add column by column, like at school

The idea: From the right: add the two digits and the carry, keep total % 10, carry total // 10.

The waste in Okay: it turns all the text into a number, when each column needs only two digits.

total % 10 is the digit to write, and total // 10 is the carry, 0 or 1. The loop goes on while there’s a digit or a carry left, so "999" plus "1" gets its last 1.

Highlighted lines are new in this rung.

def add_as_text(a: str, b: str) -> str:
result = ""
carry = 0
i, j = len(a) - 1, len(b) - 1
while i >= 0 or j >= 0 or carry:
total = carry
if i >= 0:
total += int(a[i])
i -= 1
if j >= 0:
total += int(b[j])
j -= 1
result = str(total % 10) + result
carry = total // 10
return result
Time
O(n²)
Space
O(n)
Why
Each of the n digits is put in front of the result, which copies the whole result.
What changed from the previous rung
def add_as_text(a: str, b: str) -> str:
return str(int(a) + int(b))
result = ""
carry = 0
i, j = len(a) - 1, len(b) - 1
while i >= 0 or j >= 0 or carry:
total = carry
if i >= 0:
total += int(a[i])
i -= 1
if j >= 0:
total += int(b[j])
j -= 1
result = str(total % 10) + result
carry = total // 10
return result

Where it breaks: str(total % 10) + result makes a new string and copies all of result into it, for every digit. With a million digits, that’s about 500 billion characters copied, and it runs out of time.

Best

Collect the digits, join once

The idea: Append each digit to a list, then return ''.join(reversed(digits)).

The waste in Better: it copies the answer so far, for every digit it adds.

The digits come out from right to left, so they’re reversed once, at the end, when join() builds the text in one go.

Highlighted lines are new in this rung.

def add_as_text(a: str, b: str) -> str:
digits = []
carry = 0
i, j = len(a) - 1, len(b) - 1
while i >= 0 or j >= 0 or carry:
total = carry
if i >= 0:
total += int(a[i])
i -= 1
if j >= 0:
total += int(b[j])
j -= 1
digits.append(str(total % 10))
carry = total // 10
return "".join(reversed(digits))
Time
O(n)
Space
O(n)
Why
One pass over the digits; append() is O(1), and join() copies each digit once.
What changed from the previous rung
def add_as_text(a: str, b: str) -> str:
result = ""
digits = []
carry = 0
i, j = len(a) - 1, len(b) - 1
while i >= 0 or j >= 0 or carry:
total = carry
if i >= 0:
total += int(a[i])
i -= 1
if j >= 0:
total += int(b[j])
j -= 1
result = str(total % 10) + result
digits.append(str(total % 10))
carry = total // 10
return result
return "".join(reversed(digits))

When Best isn’t best: when you control the program, sys.set_int_max_str_digits(0) lifts the limit, and int does the sum in one line. Keep the limit on for text from users.

A few quick questions. Ones you miss are remembered, so you can find your weak spots later.

  1. Predict the outputWhat does this print?
    print(0.1 + 0.2 == 0.3)
  2. Pick the patternPrices come in as text, like "19.99". Which keeps an order's total exact to the cent, with half a cent rounding up?
  3. Predict the outputWhat does this print?
    s = "Python"
    print(s[1:4], s[-2:], s[::-1])
  4. Spot the bugThis should tidy each name. Why do the names keep their spaces?
    names = ["  Ann ", " Bo"]
    for name in names:
        name.strip()
    print(names)
  5. Multiple choiceWhat is -7 // 2 in Python?

Add these to your review deck. Each card comes back a little later every time you get it right.

  • 0.1 + 0.2 != 0.3: a float is the nearest binary fraction. Compare floats with math.isclose().
  • Money: Decimal("19.99") from text, then quantize(Decimal("0.01"), rounding=ROUND_HALF_UP). Or whole cents in an int.
  • round() sends halves to the even neighbour: round(2.5) is 2.
  • // divides and rounds down, % is what’s left, and / always gives a float.
  • s[start:stop:step]: the stop is never taken, a negative place counts from the back, and s[::-1] is the reverse.
  • A string can’t change: keep what strip(), upper() and replace() give back.
  • f"{x:.2f}", f"{x:>8}", f"{n:,}", f"{x=}". A width is the least, not the most.
  • Build long text with a list and "".join(pieces).

Can you do these now?

If you’re not sure of any of these, scroll back to that part before moving on.

Status: Not started