diff --git a/bit_manipulation/next_higher_same_ones.py b/bit_manipulation/next_higher_same_ones.py new file mode 100644 index 000000000000..b44c012ebbf9 --- /dev/null +++ b/bit_manipulation/next_higher_same_ones.py @@ -0,0 +1,60 @@ +"""Next higher integer with the same number of set bits (SNOOB). +author: @0xPrashanthSec + +Given a non-negative integer n, return the next higher integer that has the same +number of 1 bits in its binary representation. If no such number exists within +Python's unbounded int range (practically always exists unless n is 0 or all +ones packed at the most significant end for fixed-width), this implementation +returns -1. + +This is the classic SNOOB algorithm from "Hacker's Delight". + +Reference: https://graphics.stanford.edu/~seander/bithacks.html#NextBitPermutation + +>>> next_higher_same_ones(0b0011) +5 +>>> bin(next_higher_same_ones(0b0011)) +'0b101' +>>> bin(next_higher_same_ones(0b01101)) # 13 -> 14 (0b01110) +'0b1110' +>>> next_higher_same_ones(1) +2 +>>> next_higher_same_ones(0) # no higher with same popcount +-1 +>>> next_higher_same_ones(-5) # negative not allowed +Traceback (most recent call last): + ... +ValueError: input_value must be a non-negative integer +""" + +from __future__ import annotations + + +def next_higher_same_ones(input_value: int) -> int: + """Return the next higher integer with the same number of set bits as the input. + + :param input_value: Non-negative integer + :return: Next higher integer with same popcount or -1 if none + :raises ValueError: if input_value < 0 + """ + if input_value < 0: + raise ValueError("input_value must be a non-negative integer") + if input_value == 0: + return -1 + + # snoob algorithm + # c = rightmost set bit + c = input_value & -input_value + # r = ripple carry: add c to input_value + r = input_value + c + if r == 0: + return -1 + # ones = pattern of ones that moved from lower part + ones = ((r ^ input_value) >> 2) // c + return r | ones + + +if __name__ == "__main__": # pragma: no cover + import doctest + + doctest.testmod() diff --git a/ciphers/columnar_transposition.py b/ciphers/columnar_transposition.py new file mode 100644 index 000000000000..d697db6e1176 --- /dev/null +++ b/ciphers/columnar_transposition.py @@ -0,0 +1,127 @@ +"""Columnar Transposition cipher. + +This classical cipher writes the plaintext in rows under a keyword and reads +columns in the order of the alphabetical rank of the keyword letters. + +Reference: https://en.wikipedia.org/wiki/Transposition_cipher#Columnar_transposition + +We keep spaces and punctuation. Key must be alphabetic (case-insensitive). + +>>> pt = "WE ARE DISCOVERED. FLEE AT ONCE" +>>> ct = encrypt(pt, "ZEBRAS") +>>> decrypt(ct, "ZEBRAS") == pt +True + +Edge cases: +>>> encrypt("HELLO", "A") +'HELLO' +>>> decrypt("HELLO", "A") +'HELLO' +>>> encrypt("HELLO", "HELLO") +'EHLLO' +>>> decrypt("EHLLO", "HELLO") +'HELLO' +>>> encrypt("HELLO", "") +Traceback (most recent call last): + ... +ValueError: Key must be a non-empty alphabetic string +""" + +from __future__ import annotations + + +def _normalize_key(key: str) -> str: + k = "".join(ch for ch in key.upper() if ch.isalpha()) + if not k: + raise ValueError("Key must be a non-empty alphabetic string") + return k + + +def _column_order(key: str) -> list[int]: + # Stable sort by character then original index to handle duplicates + indexed = list(enumerate(key)) + return [ + i + for i, _ in sorted( + indexed, key=lambda indexed_pair: (indexed_pair[1], indexed_pair[0]) + ) + ] + + +def encrypt(plaintext: str, key: str) -> str: + """Encrypt using columnar transposition. + + :param plaintext: Input text (any characters) + :param key: Alphabetic keyword + :return: Ciphertext + :raises ValueError: on invalid key + """ + k = _normalize_key(key) + cols = len(k) + if cols == 1: + return plaintext + + order = _column_order(k) + + # Build ragged rows without padding + rows = (len(plaintext) + cols - 1) // cols + grid: list[str] = [plaintext[i * cols : (i + 1) * cols] for i in range(rows)] + + # Read columns in sorted order, skipping missing cells + out: list[str] = [] + for col in order: + for r in range(rows): + if col < len(grid[r]): + out.append(grid[r][col]) + return "".join(out) + + +def decrypt(ciphertext: str, key: str) -> str: + """Decrypt columnar transposition ciphertext. + + :param ciphertext: Encrypted text + :param key: Alphabetic keyword + :return: Decrypted plaintext + :raises ValueError: on invalid key + """ + k = _normalize_key(key) + cols = len(k) + if cols == 1: + return ciphertext + + order = _column_order(k) + text_len = len(ciphertext) + rows = (text_len + cols - 1) // cols + r = text_len % cols + + # Column lengths based on ragged last row (no padding during encryption) + col_lengths: list[int] = [] + for c in range(cols): + if r == 0: + col_lengths.append(rows) + else: + col_lengths.append(rows if c < r else rows - 1) + + # Slice ciphertext into columns following the sorted order + columns: list[str] = [""] * cols + idx = 0 + for col in order: + ln = col_lengths[col] + columns[col] = ciphertext[idx : idx + ln] + idx += ln + + # Rebuild plaintext row-wise + out: list[str] = [] + pointers = [0] * cols + for _ in range(rows * cols): + c = len(out) % cols + if pointers[c] < len(columns[c]): + out.append(columns[c][pointers[c]]) + pointers[c] += 1 + return "".join(out) + + +if __name__ == "__main__": # pragma: no cover + import doctest + + doctest.testmod() diff --git a/ciphers/skytale_cipher.py b/ciphers/skytale_cipher.py new file mode 100644 index 000000000000..11fe83e5c45c --- /dev/null +++ b/ciphers/skytale_cipher.py @@ -0,0 +1,103 @@ +"""Scytale (Skytale) transposition cipher. + +A classical transposition cipher used in ancient Greece. The sender wraps a +strip of parchment around a rod (scytale) and writes the message along the rod. +The recipient with a rod of the same diameter can read the message. + +Reference: https://en.wikipedia.org/wiki/Scytale + +Functions here keep characters as-is (including spaces). The key is a positive +integer representing the circumference count (number of rows). + +>>> encrypt("WE ARE DISCOVERED FLEE AT ONCE", 3) +'WA SVEFETNERDCEDL C EIOR EAOE' +>>> decrypt('WA SVEFETNERDCEDL C EIOR EAOE', 3) +'WE ARE DISCOVERED FLEE AT ONCE' + +Edge cases: +>>> encrypt("HELLO", 1) +'HELLO' +>>> decrypt("HELLO", 1) +'HELLO' +>>> encrypt("HELLO", 5) # key equals length +'HELLO' +>>> decrypt("HELLO", 5) +'HELLO' +>>> encrypt("HELLO", 0) +Traceback (most recent call last): + ... +ValueError: Key must be a positive integer +>>> decrypt("HELLO", -2) +Traceback (most recent call last): + ... +ValueError: Key must be a positive integer +""" + +from __future__ import annotations + + +def encrypt(plaintext: str, key: int) -> str: + """Encrypt plaintext using Scytale transposition. + + Write characters around a rod with `key` rows, then read off by rows. + + :param plaintext: Input message to encrypt + :param key: Positive integer number of rows + :return: Ciphertext string + :raises ValueError: if key <= 0 + """ + if key <= 0: + raise ValueError("Key must be a positive integer") + if key == 1 or len(plaintext) <= key: + return plaintext + + # Read every key-th character starting from each row offset + return "".join(plaintext[row::key] for row in range(key)) + + +def decrypt(ciphertext: str, key: int) -> str: + """Decrypt Scytale ciphertext. + + Reconstruct rows by their lengths and interleave by columns. + + :param ciphertext: Encrypted string + :param key: Positive integer number of rows + :return: Decrypted plaintext + :raises ValueError: if key <= 0 + """ + if key <= 0: + raise ValueError("Key must be a positive integer") + if key == 1 or len(ciphertext) <= key: + return ciphertext + + length = len(ciphertext) + base = length // key + extra = length % key + + # Determine each row length + row_lengths: list[int] = [base + (1 if r < extra else 0) for r in range(key)] + + # Slice ciphertext into rows + rows: list[str] = [] + idx = 0 + for r_len in row_lengths: + rows.append(ciphertext[idx : idx + r_len]) + idx += r_len + + # Pointers to current index in each row + pointers = [0] * key + + # Reconstruct by taking characters column-wise across rows + result_chars: list[str] = [] + for i in range(length): + r = i % key + if pointers[r] < len(rows[r]): + result_chars.append(rows[r][pointers[r]]) + pointers[r] += 1 + return "".join(result_chars) + + +if __name__ == "__main__": # pragma: no cover + import doctest + + doctest.testmod()