Skip to content

Improve is_isogram performance by removing unnecessary sorting - #15318

Open
Miladkhoshdel wants to merge 1 commit into
TheAlgorithms:masterfrom
Miladkhoshdel:perf/remove-isogram-sorting
Open

Miladkhoshdel wants to merge 1 commit into
TheAlgorithms:masterfrom
Miladkhoshdel:perf/remove-isogram-sorting

Conversation

@Miladkhoshdel

@Miladkhoshdel Miladkhoshdel commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Describe your change

  • Add an algorithm?
  • Fix a bug or typo in an existing algorithm?
  • Add or change doctests? -- Note: Please avoid changing both code and tests in a single pull request.
  • Documentation change?
  • Improve the performance of an existing algorithm?

is_isogram() previously sorted all lowercase characters before converting them
to a set. Sorting is unnecessary because character order does not affect duplicate
detection.

Removing sorted() reduces the expected time complexity from O(n log n) to
O(n) while preserving the existing behavior.

Benchmark

The benchmark compares the old and new implementations using five repetitions
with five calls per repetition. Before timing, it verifies both implementations
with 23 expected-result cases and 9,331 exhaustive comparison cases.

Input size Old median New median Median speedup
260,000 15.354 ms 4.783 ms 3.21x
1,300,000 74.274 ms 23.969 ms 3.10x
2,600,000 150.817 ms 48.706 ms 3.10x
Full benchmark results and reproduction script
Benchmarking 260,000 characters...
Old version: min=14.025, median=15.354, max=18.327 ms/call
New version: min=4.681, median=4.783, max=5.021 ms/call
Median speedup: 3.21x

Benchmarking 1,300,000 characters...
Old version: min=73.256, median=74.274, max=75.424 ms/call
New version: min=23.726, median=23.969, max=24.077 ms/call
Median speedup: 3.10x

Benchmarking 2,600,000 characters...
Old version: min=149.450, median=150.817, max=152.697 ms/call
New version: min=48.544, median=48.706, max=49.062 ms/call
Median speedup: 3.10x

Benchmark script

from itertools import product
from statistics import median
from timeit import repeat


def is_isogram_old(string: str) -> bool:
    if not all(char.isalpha() for char in string):
        raise ValueError("String must only contain alphabetic characters.")

    letters = sorted(string.lower())
    return len(letters) == len(set(letters))


def is_isogram_new(string: str) -> bool:
    if not all(char.isalpha() for char in string):
        raise ValueError("String must only contain alphabetic characters.")

    letters = string.lower()
    return len(letters) == len(set(letters))


def get_outcome(function, text: str) -> tuple:
    try:
        return ("return", function(text))
    except Exception as error:
        return ("raise", type(error), str(error))


def verify_results() -> None:
    expected_cases = [
        ("", True),
        ("a", True),
        ("A", True),
        ("Aa", False),
        ("isogram", True),
        ("lamp", True),
        ("Uncopyrightable", True),
        ("Dermatoglyphics", True),
        ("ambidextrously", True),
        ("allowance", False),
        ("Alphabet", False),
        ("abcdefghijklmnopqrstuvwxyz", True),
        ("ABCDEFGHIJKLMNOPQRSTUVWXYZ", True),
        ("abcdefghijklmnopqrstuvwxyza", False),
        ("é", True),
        ("Éé", False),
        ("αβγδεζηθικλμνξοπρστυφχψω", True),
        ("hello world", ValueError),
        ("six-year-old", ValueError),
        ("copy1", ValueError),
        ("test!", ValueError),
        ("a_b", ValueError),
        ("a" * 10_000, False),
    ]

    for text, expected in expected_cases:
        for function in (is_isogram_old, is_isogram_new):
            outcome = get_outcome(function, text)
            if isinstance(expected, type) and issubclass(expected, Exception):
                assert outcome[0] == "raise" and outcome[1] is expected
            else:
                assert outcome == ("return", expected)

    generated_count = 0
    for size in range(6):
        for characters in product("aAbB1 ", repeat=size):
            text = "".join(characters)
            assert get_outcome(is_isogram_old, text) == get_outcome(
                is_isogram_new, text
            )
            generated_count += 1

    print(
        f"Passed {len(expected_cases)} expected-result cases and "
        f"{generated_count:,} exhaustive comparison cases."
    )


def benchmark() -> None:
    repeat_count = 5
    calls_per_repeat = 5

    for repetitions in (10_000, 50_000, 100_000):
        text = "abcdefghijklmnopqrstuvwxyz" * repetitions
        print(f"\nBenchmarking {len(text):,} characters...")

        old_times = repeat(
            lambda: is_isogram_old(text),
            repeat=repeat_count,
            number=calls_per_repeat,
        )
        new_times = repeat(
            lambda: is_isogram_new(text),
            repeat=repeat_count,
            number=calls_per_repeat,
        )

        old_times_ms = [time / calls_per_repeat * 1_000 for time in old_times]
        new_times_ms = [time / calls_per_repeat * 1_000 for time in new_times]
        old_median = median(old_times_ms)
        new_median = median(new_times_ms)

        print(
            "Old version: "
            f"min={min(old_times_ms):.3f}, median={old_median:.3f}, "
            f"max={max(old_times_ms):.3f} ms/call"
        )
        print(
            "New version: "
            f"min={min(new_times_ms):.3f}, median={new_median:.3f}, "
            f"max={max(new_times_ms):.3f} ms/call"
        )
        print(f"Median speedup: {old_median / new_median:.2f}x")


if __name__ == "__main__":
    verify_results()
    benchmark()

Checklist

  • I have read CONTRIBUTING.md.
  • This pull request is all my own work -- I have not plagiarized.
  • I know that pull requests will not be merged if they fail the automated tests.
  • This PR only changes one algorithm file. To ease review, please open separate PRs for separate algorithms.
  • All new Python files are placed inside an existing directory.
  • All filenames are in all lowercase characters with no spaces or dashes.
  • All functions and variable names follow Python naming conventions.
  • All function parameters and return values are annotated with Python type hints.
  • All functions have doctests that pass the automated testing.
  • All new algorithms include at least one URL that points to Wikipedia or another similar explanation.
  • If this pull request resolves one or more open issues, then the description above includes the issue number(s) with a closing keyword: "Fixes #ISSUE-NUMBER".

@algorithms-keeper algorithms-keeper Bot added awaiting reviews This PR is ready to be reviewed enhancement This PR modified some existing files labels Sep 13, 2026
@cclauss

cclauss commented Sep 13, 2026

Copy link
Copy Markdown
Member

ON HOLD: Our focus is on merging or closing old pull requests before October 1st.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting reviews This PR is ready to be reviewed enhancement This PR modified some existing files on hold

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants