For the beginning, I rewrote your code to be a function, which makes it much easier for me to do rapid prototyping:
from time import perf_counter
def find_narcissistic_numbers(limit_low, limit_high):
results = []
for number in range(limit_low, limit_high + 1):
# order of the numbers
order = len(str(number))
# initializing sum
sum = 0
var = number
while var > 0:
digit = var % 10
sum += digit ** order
var //= 10
# setting restrictions for program
if number == sum:
results.append(number)
return results
def benchmark(method):
t_start = perf_counter()
result = method(100, 500000)
t_end = perf_counter()
print(f'{method.__name__}\t {1000*(t_end-t_start):.2f} ms')
if result != [153, 370, 371, 407, 1634, 8208, 9474, 54748, 92727, 93084]:
print(f'{method.__name__} failed! Result incorrect!')
benchmark(find_narcissistic_numbers)
Which currently gives me
find_narcissistic_numbers 1047.52 ms
With that as a starting point, my ideas are:
- try to find algorithmic improvements. That might be hard because the problem is already quite simple.
- benchmark each part of this code, try to find more efficient alternatives for e.g.
len(str())
- port the critical part of the code to rust (using pyo3) for improved performance
First round
I tried to combine calculating order
and splitting the number into digits in one operation. Sadly, it did not yield any performance gain.
def find_narcissistic_numbers(limit_low, limit_high):
results = []
for number in range(limit_low, limit_high + 1):
i = number
digits = []
while i > 0:
digits.append(i % 10)
i //= 10
order = len(digits)
sum_value = 0
for digit in digits:
sum_value += digit ** order
if number == sum_value:
results.append(number)
return results
find_narcissistic_numbers 1050.50 ms
Second round
After thinking about it more, I don't find any other optimization points. I think your original algorithm is quite the optimum.
So to get any further performance increase, we need to start at a lower level of performance optimization.
Python is in its nature quite a slow language, as it has an entire interpretation layer in between its commands and actual hardware. Therefore it would be beneficial to extract the critical code to another language that compiles directly to native bytecode and uses features like vectorization.
Those are the most promising options in my opinion, as they fulfill that criterium and can be used to implement python functions:
I personally find Rust to be the most convenient function to write Python code, as it automatically makes sure that all the memory management is done right.
I use this skeleton to write the Rust function for Python:
https://github.com/Finomnis/PythonCModule
lib.rs:
use pyo3::prelude::*;
use pyo3::wrap_pyfunction;
#[pyfunction]
fn find_narcissistic_numbers_rust(_py: Python, limit_low: u32, limit_high: u32) -> PyResult<Vec<u32>> {
let mut results = vec![];
for number in limit_low..=limit_high {
let mut digits = vec![];
let mut i = number;
while i > 0 {
digits.push(i%10);
i /= 10;
}
let order = digits.len() as u32;
let sum = digits.iter().fold(0, |acc, x| acc + x.pow(order));
if number == sum {
results.push(number);
}
}
Ok(results)
}
/// A Python module implemented in Rust.
#[pymodule]
fn __lib(_py: Python, m: &PyModule) -> PyResult<()> {
m.add_function(wrap_pyfunction!(find_narcissistic_numbers_rust, m)?)?;
Ok(())
}
python:
from time import perf_counter
from narcissisticNumbersRustModule import find_narcissistic_numbers_rust
def benchmark(method):
t_start = perf_counter()
result = method(100, 500000)
t_end = perf_counter()
print(f'{method.__name__}\t {1000*(t_end-t_start):.2f} ms')
if result != [153, 370, 371, 407, 1634, 8208, 9474, 54748, 92727, 93084]:
print(f'{method.__name__} failed! Result incorrect!')
benchmark(find_narcissistic_numbers_rust)
Result:
find_narcissistic_numbers_rust 84.00 ms
So that's about ~12 times faster!
Third round
Seeing Marc's answer made it painfully clear that algorithmic optimization is by far the most important part and should always be done first.
I ran his code on my machine, to be able to compare it with my attempts:
def armstrong(limit_low, limit_high):
numbers = []
order = len(str(limit_high))
for k in range(1, order+1):
a = [i ** k for i in range(10)]
for b in combinations_with_replacement(range(10), k):
x = sum(map(lambda y: a[y], b))
if x > 0 and tuple(int(d) for d in sorted(str(x))) == b:
numbers.append(x)
return sorted(filter(lambda x: limit_low <= x <= limit_high, numbers))
And the result was:
armstrong 20.71 ms
Optimizing in hardware efficiency, by using a faster programming language or utilizing hardware features, only ever gives you a linear speedup.
Algorithmic optimization, however, has the chance to move your algorithm into a different complexity class, which can boost the speed exponentially. Literally exponentially, not a figure of speech.
Now that we know the better algorithm, let's see if we can squeeze a little bit of juice out by porting it to Rust: (sorry, i'm just really in love with Rust recently :D )
use pyo3::prelude::*;
use pyo3::wrap_pyfunction;
use std::iter;
use itertools::Itertools;
struct CombinationsWithReplacementIterator {
current: Vec<u32>
}
impl Iterator for CombinationsWithReplacementIterator {
type Item = Vec<u32>;
fn next(&mut self) -> Option<Vec<u32>> {
let mut overflow = true;
for element in self.current.iter_mut().rev() {
if *element == 9 {
*element = 0;
} else {
*element += 1;
overflow = false;
break;
}
}
let mut prev_element = 0;
for element in self.current.iter_mut() {
if *element < prev_element {
*element = prev_element;
}
prev_element = *element;
}
if overflow {
None
} else {
Some(self.current.clone())
}
}
}
fn combinations_with_replacement(order: usize) -> CombinationsWithReplacementIterator {
CombinationsWithReplacementIterator{current:iter::repeat(0).take(order).collect()}
}
#[pyfunction]
fn armstrong_rust(_py: Python, limit_low: u32, limit_high: u32) -> PyResult<Vec<u32>> {
let mut results = vec![];
let order = limit_high.to_string().len() as u32;
for current_order in 1..=order {
let power_table: Vec<u32> = (0..10).map(|x:u32| x.pow(current_order)).collect();
for combination in combinations_with_replacement(current_order as usize) {
let number: u32 = combination.iter().map(|&x| power_table[x as usize]).sum();
let mut number_digits = vec![];
{
let mut i = number;
while i > 0 {
number_digits.push(i%10);
i /= 10;
}
}
if number_digits.iter().sorted().zip(combination.iter()).all(|(a,b)| *a == *b) {
results.push(number);
}
}
}
Ok(results.into_iter().filter(|&x| x>=limit_low && x<=limit_high).sorted().collect())
}
/// A Python module implemented in Rust.
#[pymodule]
fn __lib(_py: Python, m: &PyModule) -> PyResult<()> {
m.add_function(wrap_pyfunction!(armstrong_rust, m)?)?;
Ok(())
}
Result:
armstrong_rust 2.73 ms
Summary
- Algorithmic optimizations are always highest priority
- When best algorithm is established, further low level optimization can be done
Performance comparison:
find_narcissistic_numbers 1047.52 ms
find_narcissistic_numbers_rust 84.00 ms
armstrong 20.71 ms
armstrong_rust 2.73 ms
sum
. \$\endgroup\$