Python constant-factor engineering at scale
- Module
- M7.12
- Lesson
- 1 of 1
- Reading time
- 5 min
In this lesson
- Identify performance bottlenecks in Python programs that run on PyPy.
- Use flat 1D lists and parallel lists instead of tuples and dictionaries.
- Encode integers into single values to reduce memory and cache misses.
- Read input in one call, join output, and measure on maximum-size test cases.
- Know when further optimisation returns diminishing marks and when to stop.
Your algorithm is correct. Its complexity is right. But when you submit to the judge, it times out. The issue is not the algorithm itself. It is the speed at which Python executes your code, instruction by instruction.
Python is slower than C by nature. The contest grader runs on PyPy 3.8, which compiles frequently-executed loops to native machine code and dramatically speeds up Python. However, PyPy does not accelerate all Python code equally. The choices you make about input parsing, data structures, and loop structure determine whether you meet the time limit.
Reading input once
Calling input() in a loop for hundreds of thousands of lines is extremely slow. Each call involves line parsing, buffer management, and string allocation. In a tight loop, this overhead dominates and consumes the time that should go to your actual computation.
Instead, use sys.stdin.read().split(). This single call reads all input, and you then extract tokens by position from the resulting list. You saw this pattern in M3.10.
import sys
# Slow: reading with input() in a loopdef slow_read(): n = int(input()) total = 0 for _ in range(n): x = int(input()) total += x return total
# Fast: reading everything at oncedef fast_read(): data = sys.stdin.read().split() n = int(data[0]) total = 0 for i in range(1, n + 1): total += int(data[i]) return total
def main() -> None: result = fast_read() sys.stdout.write(f"{result}\n")
if __name__ == "__main__": main()Input
5
10
20
15
25
30Output
100The first version calls input() on each line. The second reads everything at once and indexes the list. Both do the same work, but the second is faster.
Flat lists and parallel lists
Storing data in tuples or dictionaries makes sense in small programs. On a large test case, access time adds up.
A list of tuples like [(3, 5), (1, 7), (2, 4)] looks clean. But each lookup first finds the tuple, then indexes into it. If you have 100,000 points, that is 200,000 attribute accesses.
Instead, use two flat lists: one for the first value and one for the second. Index both lists with the same integer. A list lookup on an integer index is just an array access. PyPy's compiler can make it very cheap.
import sys
def main() -> None: data = sys.stdin.read().split() n = int(data[0])
# Read points into parallel lists x_coords = [] y_coords = [] for i in range(1, 2 * n + 1, 2): x_coords.append(int(data[i])) y_coords.append(int(data[i + 1]))
# Process: find sum of all coordinates total = sum(x_coords) + sum(y_coords) sys.stdout.write(f"{total}\n")
if __name__ == "__main__": main()Input
3
1 2
4 5
3 6Output
21Here we have 100,000 points. The tuple version stores them compactly in memory. The flat-list version uses two separate lists. When you process a point, you index both lists. This is faster on PyPy because the compiler optimises integer indexing heavily.
Packing integers
Sometimes you have data you want to encode compactly. For example, you might have a node number (0 to 10,000) and a time value (0 to 1,000). Instead of storing them separately or as a tuple, pack both into a single integer.
Multiply the node by a large enough constant and add the time. Then when you need the node, divide by that constant and take the remainder.
import sys
def main() -> None: data = sys.stdin.read().split() n = int(data[0])
# Pack node (0-10000) and time (0-1000) into one integer PACK = 10001 packed = [] for i in range(1, 2 * n + 1, 2): node = int(data[i]) time = int(data[i + 1]) packed_value = node * PACK + time packed.append(packed_value)
# Sort packed values packed.sort()
# Output unpacked values output = [] for p in packed: node = p // PACK time = p % PACK output.append(f"{node} {time}")
sys.stdout.write("\n".join(output) + "\n")
if __name__ == "__main__": main()Input
3
5 100
2 500
5 200Output
2 500
5 100
5 200A packed integer stores two values in one. When you sort the packed values, you sort by node first, then by time. The sorting is faster because you are moving fewer bytes. Dictionary lookups are also faster when your key is a single integer instead of a tuple.
Avoiding copies
Slicing a list creates a new copy. Joining strings creates copies of the input. These copies use memory and time.
When you do not need a copy, avoid creating one. Read from the original list. Append to an output list and join it once at the end, instead of concatenating strings in a loop.
import sys
def main() -> None: data = sys.stdin.read().split() n = int(data[0])
# Slow: concatenating strings # result = "" # for i in range(1, n + 1): # result += data[i] + " "
# Fast: appending to list and joining once output = [] for i in range(1, n + 1): output.append(data[i])
sys.stdout.write(" ".join(output) + "\n")
if __name__ == "__main__": main()Input
3
hello
world
testOutput
hello world testThe first version concatenates total in a loop. Each concatenation creates a new string. The second version appends to a list and joins once. On large outputs, this is much faster.
A complete example: sorting pairs by custom order
Imagine you have to sort pairs by one field when it is small, and by the other field when the first is tied. This is a common CCC task.
The slow way is to write a custom comparison function and call the sort. A faster way is to pack both values, compute a sort key, and sort by the key.
import sys
def main() -> None: data = sys.stdin.read().split() n = int(data[0])
# Pack pairs (a, b) where we sort by a then b PACK = 1000 pairs = [] for i in range(1, 2 * n + 1, 2): a = int(data[i]) b = int(data[i + 1]) packed = a * PACK + b pairs.append(packed)
pairs.sort()
# Unpack and output output = [] for p in pairs: a = p // PACK b = p % PACK output.append(f"{a} {b}")
sys.stdout.write("\n".join(output) + "\n")
if __name__ == "__main__": main()Input
4
5 2
2 8
5 1
2 3Output
2 3
2 8
5 1
5 2The pairs are sorted by the first value, then by the second value when the first is tied. The packing approach sorts on a single integer per pair. This is faster than using a key function that unpacks them during each comparison.
When to measure and when to stop
You have finished the algorithm. You code it up. You test on sample inputs and the output is right. The judge times out on the larger subtasks.
Before you start optimising, measure where the time is spent. Run the largest test case you can fit in memory and see which part of your code takes time.
If your algorithm's complexity is bad, no constant-factor trick will save it. Fix the algorithm first.
If the algorithm is fine but you are still too slow, use a profiler or add timing code to see which loop or which operation uses most of the time. Then optimise that part. A smaller optimisation elsewhere will not help.
import sysimport time
def main() -> None: t_read = time.time() data = sys.stdin.read().split() t_parse = time.time()
n = int(data[0]) total = 0
t_compute = time.time() for i in range(1, n + 1): total += int(data[i]) t_done = time.time()
read_time = (t_parse - t_read) * 1000 parse_time = (t_compute - t_parse) * 1000 compute_time = (t_done - t_compute) * 1000
sys.stdout.write(f"{total}\n") sys.stderr.write(f"Read: {read_time:.2f}ms Parse: {parse_time:.2f}ms Compute: {compute_time:.2f}ms\n")
if __name__ == "__main__": main()Input
4
10
20
30
40Output
100This program solves a small example and prints the time each part took. When you run it on a large test case, you see what is slow.
Another fact about CCC: the grader keeps your best submission. If the fast solution would take you longer to code than you have, submit the simpler solution first. If the fast solution is risky or complex, code it safely, so a crash never replaces working code.
The house skeleton for fast I/O
You have already seen the house skeleton in M3.10. Here it is again.
import sys
def main() -> None: input_data = sys.stdin.read().split() if not input_data: return # algorithm implementation output = [] # ... build output list ... sys.stdout.write("\n".join(output))
if __name__ == "__main__": main()This is the shape to use for any problem with large input. It reads once, processes, and writes once. Use it as your default for senior problems. All the examples in this lesson use this shape.
Recap
A fast Python program reads input once with sys.stdin.read().split(), stores data in flat 1D lists or packed integers, avoids slicing and copying, and keeps loops tight. PyPy compiles hot loops to machine code, but only if the code is simple and uses cheap operations like integer indexing.
Before you optimise, measure. After you optimise, measure again. Know the complexity limit for partial marks and decide when to stop.
Practice
Try these on the judge. Each link opens the problem on DMOJ.
- 2019 S4Tourism (opens on DMOJ in a new tab) DMOJ
Find the minimum cost to process a stream of updates to a flat array.
Why DMOJ: An older large-input problem, good for practicing fast I/O and tight loops.
- 2015 S5Greedy For Pies (opens on DMOJ in a new tab) DMOJ
Compute the shortest meeting time for scheduled intervals.
Why DMOJ: An older problem that builds timing-critical fast-I/O and loop skills.