Foundationপ্রথম নীতি থেকে
LEVEL 0লেসন ৯/১৬মাঝারি৫৫ মিনিট

Functions — injection, surjection আর তথ্য হারানোর গণিত

Functions and Their Properties

একটা function কখন উল্টানো যায় আর কখন যায় না — এই একটা প্রশ্নের উত্তর ব্যাখ্যা করে hash collision, compression-এর সীমা, encoding আর lossy/lossless-এর পার্থক্য।

এই লেসন শেষে আপনি পারবেন

  • Function-কে relation-এর বিশেষ ক্ষেত্র হিসেবে সংজ্ঞায়িত করতে পারবেন
  • Injective, surjective, bijective আলাদা করতে পারবেন এবং যাচাই করতে পারবেন
  • একটা function invertible কি না বলতে পারবেন এবং কেন
  • Pigeonhole দিয়ে hash collision ও compression-এর সীমা প্রমাণ করতে পারবেন
  • Function composition ও তার ধর্ম প্রয়োগ করতে পারবেন

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

তিনটা প্রশ্ন, একটাই গাণিতিক উত্তর:

  • “আমার hash function কি collision-মুক্ত বানানো সম্ভব?”
  • “একটা compression algorithm কি সব ফাইল ছোট করতে পারে?”
  • “এই encoding থেকে কি মূল ডেটা ফিরে পাওয়া যাবে?”

তিনটার উত্তরই নির্ভর করে একটা ধর্মের উপর: function-টা injective কি না — অর্থাৎ দুইটা ভিন্ন input কি একই output দিতে পারে?

Function আপনি প্রতিদিন লেখেন। কিন্তু গাণিতিক function আর programming-এর function এক জিনিস নয় — আর পার্থক্যটা জানা থাকলে অনেক bug আগেই ধরা পড়ে।

মূল ধারণা

Function হলো একটা বিশেষ relation

গত লেসনে relation দেখেছি: R ⊆ A × B

একটা relation f : A → B function যদি:

aA,  !bB:(a,b)f\forall a \in A,\; \exists!\, b \in B : (a,b) \in f

∃! মানে “ঠিক একটা আছে”। দুইটা শর্ত এতে লুকানো:

  1. Total — প্রতিটা a-এর জন্য অন্তত একটা b আছে
  2. Deterministic — সর্বোচ্চ একটা b আছে
Function ✓            Function নয় (total নয়)     Function নয় (একাধিক output)

a ──→ 1               a ──→ 1                    a ──→ 1
b ──→ 2               b                          │└─→ 2
c ──→ 1               c ──→ 2                    b ──→ 2
একই ছবি তিনভাবে — কোনটা function, কোনটা নয়।

তিনটা শব্দ আলাদা করে মনে রাখুন:

শব্দমানে
Domainযেখান থেকে input আসে (A)
Codomainযেখানে output থাকতে পারে (B)
Range (বা image)যেসব output আসলে আসে (B-এর উপসেট)

Range সবসময় codomain-এর উপসেট, কিন্তু সমান নাও হতে পারে।

def f(x: int) -> int:
    return x * x
  • Domain: int
  • Codomain: int (type annotation যা বলছে)
  • Range: শুধু পূর্ণবর্গ — {0, 1, 4, 9, 16, …}

Type system codomain বলে, range বলে না। এই ফাঁকটাই কারণ যে type-correct কোডও ভুল হতে পারে।

Partial function

যে “function” domain-এর সব element-এ সংজ্ঞায়িত নয় তাকে বলে partial function

f : ℝ ⇀ ℝ,  f(x) = 1/x        x = 0 -এ সংজ্ঞায়িত নয়

Programming-এ এটা সর্বত্র, আর ভাষাগুলো এটা তিনভাবে সামলায়:

কৌশলউদাহরণ
Exception ছোঁড়াPython-এর ZeroDivisionError
Optional/Maybe ফেরতRust-এর Option<T>, Haskell-এর Maybe
Domain সংকুচিত করাNonZeroU32 type

তৃতীয়টা সবচেয়ে ভালো যখন সম্ভব — তখন partial function-টা total হয়ে যায়, আর ভুল অবস্থাটা compile-time-এ অসম্ভব হয়ে যায়।

// partial — runtime-এ ব্যর্থ হতে পারে
fn div(a: u32, b: u32) -> u32 { a / b }

// total — b কখনো শূন্য হতে পারে না
fn div(a: u32, b: NonZeroU32) -> u32 { a / b.get() }

এটাকে বলে “make illegal states unrepresentable” — Level 5-এ type system-এ ফিরে আসব।

তিনটা মূল ধর্ম

Injective (one-to-one) — কিছু হারায় না

a1,a2A:  f(a1)=f(a2)    a1=a2\forall a_1, a_2 \in A:\; f(a_1) = f(a_2) \implies a_1 = a_2

সমতুল্যভাবে (contrapositive): ভিন্ন input → ভিন্ন output।

Injective ✓           Injective নয় ✗

a ──→ 1               a ──→ 1
b ──→ 2               b ──→ 1     ← দুইটা input, একই output
c ──→ 3               c ──→ 2

অর্থ: output দেখে input পুনরুদ্ধার করা যায় (যদি output-টা range-এ থাকে)। কোনো তথ্য হারায়নি।

Surjective (onto) — সব output ব্যবহৃত হয়

bB,  aA:f(a)=b\forall b \in B,\; \exists a \in A : f(a) = b

অর্থাৎ range = codomain

Surjective ✓          Surjective নয় ✗

a ──→ 1               a ──→ 1
b ──→ 2               b ──→ 1
c ──→ 2               c ──→ 2
                            3     ← কেউ এখানে map হয় না

Bijective — দুটোই

Injective এবং surjective। তখন এবং কেবল তখনই একটা inverse function f⁻¹ : B → A থাকে।

f1(f(a))=aএবংf(f1(b))=bf^{-1}(f(a)) = a \quad\text{এবং}\quad f(f^{-1}(b)) = b

ভেতরে কী ঘটছে

Injectivity ভাঙলে যা হয়

এখানেই তত্ত্ব বাস্তবে পরিণত হয়। তিনটা পরিণতি, তিনটাই অনিবার্য।

পরিণতি ১: Hash collision অনিবার্য

একটা hash function:

h:{0,1}{0,1}256h : \{0,1\}^* \to \{0,1\}^{256}

Domain অসীম (যেকোনো দৈর্ঘ্যের bit string), codomain সসীম (2²⁵⁶ টা মান)।

|A| > |B|injective হওয়া অসম্ভব → collision আছেই।

গত লেসনে pigeonhole দিয়ে এটা প্রমাণ করেছি। এখন function-এর ভাষায় বলা যায়: domain codomain-এর চেয়ে বড় হলে injectivity অসম্ভব।

পরিণতি ২: Lossless compression সব ফাইল ছোট করতে পারে না

একটা compression function C : {0,1}* → {0,1}* lossless হতে হলে injective হতেই হবে — নাহলে decompress করা যাবে না।

কিন্তু n bit-এর 2ⁿ টা ফাইলকে n bit-এর কম-এ map করতে চাইলে, target-এ আছে মাত্র 2ⁿ − 1 টা string। Injectivity অসম্ভব।

তাই যেকোনো lossless compressor কিছু ফাইলকে বড় করবেই।

# নিজে দেখুন
head -c 1000000 /dev/urandom > random.bin
gzip -k random.bin
ls -l random.bin random.bin.gz

Random ডেটা gzip করলে সাধারণত সামান্য বড় হয় — header আর incompressible block-এর overhead।

Compression কাজ করে কারণ বাস্তব ফাইল random নয়। Text-এ pattern আছে, image-এ সংলগ্ন pixel মিলে যায়, code-এ পুনরাবৃত্তি আছে। Compressor সেই কাঠামো কাজে লাগায় — সম্ভাব্য সব bit string-এর মধ্যে বাস্তবে যেগুলো ঘটে সেগুলোকে ছোট code দেয়।

পরিণতি ৩: Lossy মানে non-injective — ইচ্ছাকৃতভাবে

JPEG, MP3, H.264 — এরা ইচ্ছে করে injectivity ছেড়ে দেয়।

অনেকগুলো ভিন্ন image  ──→  একই JPEG ফাইল

তথ্য হারায়, কিন্তু যেটুকু হারায় সেটুকু মানুষের চোখ/কান ধরতে পারে না। বিনিময়ে অনেক বেশি compression।

একটা function-এর injectivity কী সিদ্ধান্ত ঠিক করে
  1. Injective?মূল প্রশ্ন
  2. হ্যাঁ → invertibleencoding, encryption, lossless compression
  3. base64, UTF-8, AES, gzipমূল ডেটা ফেরত পাওয়া যায়
  4. না → তথ্য হারায়দুই রকম
  5. দুর্ঘটনাক্রমে → hash, checksumcollision সহ্য করতে হয়
  6. ইচ্ছাকৃতভাবে → JPEG, MP3অনুভূত মানের বিনিময়ে আকার

Composition

দুইটা function জোড়া লাগানো:

(gf)(x)=g(f(x))(g \circ f)(x) = g(f(x))

f : A → B আর g : B → C হলে g ∘ f : A → C

ধর্ম:

(h ∘ g) ∘ f = h ∘ (g ∘ f)        associative ✓
g ∘ f ≠ f ∘ g                    commutative নয় ✗
f ∘ id = id ∘ f = f              identity

Injectivity ও surjectivity composition-এ কীভাবে চলে:

যদিতাহলে g ∘ f
f, g দুটোই injectiveinjective
f, g দুটোই surjectivesurjective
f, g দুটোই bijectivebijective, আর (g∘f)⁻¹ = f⁻¹ ∘ g⁻¹

শেষ সূত্রটার ক্রম লক্ষ্য করুন — উল্টো। মোজা তারপর জুতো পরলে, খুলতে হয় জুতো তারপর মোজা।

কোডে এটা প্রতিদিন:

# encode: compress তারপর encrypt
blob = encrypt(compress(data))

# decode: উল্টো ক্রমে
data = decompress(decrypt(blob))

Pigeonhole-এর function রূপ

গত লেসনে যা counting argument হিসেবে দেখেছি, এখন function-এর ভাষায়:

Theorem: |A| > |B| হলে কোনো function f : A → B injective নয়।

প্রমাণ (contradiction). ধরি f injective। তাহলে A-এর প্রতিটা element B-তে একটা স্বতন্ত্র মান পায়, অর্থাৎ f(A) ⊆ B আর |f(A)| = |A|

কিন্তু f(A) ⊆ B মানে |f(A)| ≤ |B|, তাই |A| ≤ |B| — যা অনুমানের বিরোধী। ∎

সরল, কিন্তু এই একটা theorem-ই hash collision, compression-এর সীমা, আর আরো বহু কিছুর ভিত্তি।

উদাহরণ

Hash function ডিজাইন — ভালো আর খারাপ

Collision এড়ানো যায় না। কিন্তু কতটা সমানভাবে ছড়ায় সেটা নিয়ন্ত্রণ করা যায়, আর সেটাই hash table-এর performance ঠিক করে।

def bad_hash(s):
    """সব অক্ষরের ASCII যোগ"""
    return sum(ord(c) for c in s)

def better_hash(s):
    """polynomial rolling hash"""
    h = 0
    for c in s:
        h = (h * 31 + ord(c)) & 0xFFFFFFFF
    return h

bad_hash-এ "abc", "acb", "bca" — সবার hash একই, কারণ যোগ commutative। যেকোনো anagram collide করে।

better_hash-এ position গুরুত্বপূর্ণ, কারণ 31-এর ভিন্ন ঘাত দিয়ে গুণ হচ্ছে।

কেন ঠিক ৩১? তিনটা কারণ:

  1. মৌলিক সংখ্যা — modulo নেওয়ার সময় pattern কম তৈরি হয়
  2. বিজোড় — জোড় হলে প্রতি গুণে একটা করে low bit হারাত
  3. 31 * h == (h \<\< 5) - h — shift আর subtract দিয়ে করা যায়, পুরনো CPU-তে গুণের চেয়ে দ্রুত ছিল

Java-র String.hashCode() আক্ষরিকভাবে এই algorithm, 31 সহ।

Bijection যেখানে কাজ করে

Base64 — binary কে ASCII-তে নিরাপদে বহন করার bijection:

3 byte (24 bit) ──→ 4 টা 6-bit group ──→ 4 টা ASCII অক্ষর

2²⁴ টা সম্ভাব্য input, আর 64⁴ = 2²⁴ টা সম্ভাব্য output। সংখ্যা সমান → bijection সম্ভব → পুরোপুরি invertible ✓

দাম: আকার 4/3 গুণ বাড়ে (৩৩% overhead)। এটা অনিবার্য — 2⁸ টা byte-value কে 64 টা নিরাপদ অক্ষরে রাখতে হলে বেশি জায়গা লাগবেই।

UTF-8 — code point থেকে byte sequence-এর injection:

U+0041 (A)     → 41              1 byte
U+00E9 (é)     → C3 A9           2 byte
U+0995 (ক)     → E0 A6 95        3 byte
U+1F600 (😀)   → F0 9F 98 80     4 byte

Injective — প্রতিটা code point-এর একটাই encoding, আর decode অস্পষ্টতাহীন। এটা UTF-8-এর নকশার একটা মূল লক্ষ্য ছিল, আর সেজন্যই continuation byte-গুলো 10xxxxxx দিয়ে শুরু হয় — মাঝখান থেকে পড়া শুরু করলেও সীমানা চেনা যায়।

Level 1-এ আমরা UTF-8 encoder নিজে লিখব।

Encryption — key ঠিক থাকলে bijection:

E_k : {0,1}ⁿ → {0,1}ⁿ        প্রতিটা k-এর জন্য একটা permutation

Block cipher আক্ষরিকভাবে একটা permutationn-bit block space-এর উপর একটা bijection। Bijection না হলে decrypt করা যেত না।

AES-128-এ block size ১২৮ bit, তাই এটা 2¹²⁸ element-এর একটা set-এর উপর একটা permutation, আর key নির্ধারণ করে কোন permutation।

নিজে চালিয়ে দেখুন

EXPERIMENT

Hash distribution মেপে দেখুন

Python 3· ১৫ মিনিট
from collections import Counter
import statistics, random, string

def bad_hash(s):
    return sum(ord(c) for c in s)

def poly_hash(s):
    h = 0
    for c in s:
        h = (h * 31 + ord(c)) & 0xFFFFFFFF
    return h

def fnv1a(s):
    h = 0x811C9DC5
    for c in s.encode():
        h = ((h ^ c) * 0x01000193) & 0xFFFFFFFF
    return h


def analyse(fn, keys, buckets=1024):
    counts = Counter(fn(k) % buckets for k in keys)
    sizes = [counts.get(i, 0) for i in range(buckets)]
    used = sum(1 for s in sizes if s)
    ideal = len(keys) / buckets
    return {
        "নাম":        fn.__name__,
        "ব্যবহৃত bucket": f"{used}/{buckets}",
        "সর্বোচ্চ":     max(sizes),
        "আদর্শ":       f"{ideal:.1f}",
        "std dev":     f"{statistics.pstdev(sizes):.2f}",
    }


# বাস্তবসদৃশ key — অনেকগুলো একই দৈর্ঘ্য ও অক্ষর
random.seed(42)
words = [''.join(random.choices(string.ascii_lowercase, k=8))
         for _ in range(20000)]

print(f"{'function':>12} {'bucket':>14} {'max':>6} {'ideal':>7} {'stddev':>8}")
for fn in (bad_hash, poly_hash, fnv1a):
    r = analyse(fn, words)
    print(f"{r['নাম']:>12} {r['ব্যবহৃত bucket']:>14} {r['সর্বোচ্চ']:>6} "
          f"{r['আদর্শ']:>7} {r['std dev']:>8}")

সাধারণ ফলাফল:

    function         bucket    max   ideal   stddev
    bad_hash        189/1024   612    19.5   62.41
   poly_hash       1024/1024    38    19.5    4.42
       fnv1a       1024/1024    36    19.5    4.38

bad_hash মাত্র ১৮৯টা bucket ব্যবহার করেছে, আর একটা bucket-এ ৬১২টা key! সেই bucket-এ lookup O(612) — কার্যত linear search।

poly_hash আর fnv1a সব bucket ব্যবহার করেছে, সর্বোচ্চ ৩৮ — আদর্শ ১৯.৫-এর কাছাকাছি।

Anagram দিয়ে bad_hash ভাঙুন:

anagrams = ["listen", "silent", "enlist", "tinsel", "inlets"]
print("\nbad_hash :", [bad_hash(w) for w in anagrams])
print("poly_hash:", [poly_hash(w) % 1000 for w in anagrams])
bad_hash : [652, 652, 652, 652, 652]      ← সব একই!
poly_hash: [431, 863, 152, 774, 209]      ← ছড়িয়ে গেছে

এবার সময় মাপুন:

import time

class SimpleHashTable:
    def __init__(self, fn, buckets=1024):
        self.fn, self.b = fn, [[] for _ in range(buckets)]
    def put(self, k, v):
        self.b[self.fn(k) % len(self.b)].append((k, v))
    def get(self, k):
        for kk, vv in self.b[self.fn(k) % len(self.b)]:
            if kk == k: return vv

for fn in (bad_hash, poly_hash):
    t = SimpleHashTable(fn)
    for w in words: t.put(w, 1)
    t0 = time.perf_counter()
    for w in words: t.get(w)
    print(f"{fn.__name__:>10}: {(time.perf_counter()-t0)*1000:7.1f} ms")

সাধারণত bad_hash ১০–২০ গুণ ধীর — একই data structure, একই algorithm, শুধু hash function আলাদা।

এটা কী প্রমাণ করে

Collision অনিবার্য, কিন্তু ভালো ও খারাপ hash-এর মধ্যে বণ্টনের পার্থক্য নাটকীয় — আর সেই পার্থক্যই hash table-এর O(1) আর O(n)-এর মধ্যে ব্যবধান।

EXPERIMENT

Compression-এর সীমা নিজে দেখুন

Linux / macOS· ১০ মিনিট
cd $(mktemp -d)

# ১. Random ডেটা — কোনো pattern নেই
head -c 1000000 /dev/urandom > random.bin

# ২. পুনরাবৃত্ত ডেটা — চরম pattern
yes "hello world" | head -c 1000000 > repeat.txt

# ৩. বাস্তব text
curl -s https://www.gutenberg.org/files/1342/1342-0.txt \
  | head -c 1000000 > book.txt 2>/dev/null \
  || man bash | head -c 1000000 > book.txt

for f in random.bin repeat.txt book.txt; do
  gzip -kf "$f"
  orig=$(wc -c \< "$f")
  comp=$(wc -c \< "$f.gz")
  printf "%-12s %9d → %9d  (%.1f%%)\n" \
    "$f" "$orig" "$comp" "$(echo "scale=2; $comp*100/$orig" | bc)"
done

সাধারণ ফলাফল:

random.bin      1000000 →   1000173  (100.0%)   ← বড় হয়েছে!
repeat.txt      1000000 →      1610  (0.2%)
book.txt        1000000 →    370415  (37.0%)

random.bin বড় হয়েছে — ১৭৩ byte বেড়েছে। এটাই pigeonhole-এর সরাসরি প্রমাণ।

এবার দ্বিগুণ compression চেষ্টা করুন:

gzip -kf book.txt
gzip -kf book.txt.gz
ls -l book.txt book.txt.gz book.txt.gz.gz

দ্বিতীয়বার gzip প্রায় সবসময় বড় করে। কারণ প্রথমবারের output-এ আর কোনো exploitable pattern নেই — এটা কার্যত random।

একটা চিন্তার খোরাক: যদি কেউ দাবি করে তার algorithm যেকোনো ফাইল ১০% ছোট করে, তাহলে সেটা বারবার প্রয়োগ করে যেকোনো ফাইলকে ১ byte-এ নামানো যেত। স্পষ্টতই অসম্ভব — আর এই যুক্তিটাই “infinite compression” দাবিদারদের খণ্ডন করার সবচেয়ে দ্রুত উপায়।

এটা কী প্রমাণ করে

Lossless compression injective হতে বাধ্য, তাই pigeonhole অনুযায়ী কিছু ফাইল বড় হবেই। Random ডেটায় সেটা সরাসরি দেখা যায়।

নিজে বানান

BUILD IT

Function Property Checker

Python · ●●○○○
  1. সসীম domain-এ একটা function-এর injective/surjective/bijective যাচাই করুন
  2. Bijective হলে inverse বানান
  3. Composition করুন এবং ধর্মগুলো কীভাবে চলে দেখুন
  4. বাস্তব encoding (base64, UTF-8) এই ধর্মগুলো মানে কি না যাচাই করুন
from collections import Counter


class FiniteFunction:
    def __init__(self, mapping, codomain=None):
        self.f = dict(mapping)
        self.domain = set(self.f)
        self.range = set(self.f.values())
        self.codomain = set(codomain) if codomain is not None else set(self.range)
        assert self.range <= self.codomain, "range codomain-এর বাইরে"

    def __call__(self, x):
        if x not in self.f:
            raise ValueError(f"{x!r} domain-এ নেই (partial function)")
        return self.f[x]

    def is_injective(self):
        return len(self.range) == len(self.domain)

    def is_surjective(self):
        return self.range == self.codomain

    def is_bijective(self):
        return self.is_injective() and self.is_surjective()

    def collisions(self):
        """কোন output একাধিক input থেকে এসেছে"""
        c = Counter(self.f.values())
        out = {}
        for y, n in c.items():
            if n > 1:
                out[y] = sorted((x for x, v in self.f.items() if v == y), key=str)
        return out

    def inverse(self):
        if not self.is_bijective():
            reason = []
            if not self.is_injective():  reason.append("injective নয়")
            if not self.is_surjective(): reason.append("surjective নয়")
            raise ValueError("inverse নেই — " + " এবং ".join(reason))
        return FiniteFunction({y: x for x, y in self.f.items()}, self.domain)

    def compose(self, other):
        """(self ∘ other)(x) = self(other(x))"""
        assert other.range <= self.domain, "composition অসম্ভব — type মেলে না"
        return FiniteFunction({x: self.f[other.f[x]] for x in other.domain},
                              self.codomain)

    def report(self, name):
        print(f"\n── {name} " + "─" * max(0, 44 - len(name)))
        print(f"  |domain| = {len(self.domain)}, |range| = {len(self.range)}, "
              f"|codomain| = {len(self.codomain)}")
        print(f"  injective  : {self.is_injective()}")
        print(f"  surjective : {self.is_surjective()}")
        print(f"  bijective  : {self.is_bijective()}")
        col = self.collisions()
        if col:
            shown = list(col.items())[:3]
            print(f"  collision  : " + ", ".join(f"{v}{k}" for k, v in shown)
                  + (" …" if len(col) > 3 else ""))
        try:
            inv = self.inverse()
            print(f"  inverse    : আছে")
        except ValueError as e:
            print(f"  inverse    : {e}")


# ── উদাহরণ ──────────────────────────────────────────────────
D = list(range(-4, 5))

FiniteFunction({x: x*x for x in D}).report("f(x) = x²")
FiniteFunction({x: x+1 for x in D}, codomain=range(-3, 6)).report("f(x) = x+1")
FiniteFunction({x: x % 3 for x in D}, codomain={0,1,2}).report("f(x) = x mod 3")
FiniteFunction({x: 0 for x in D}, codomain={0,1}).report("f(x) = 0 (ধ্রুবক)")

# ── Composition ─────────────────────────────────────────────
double = FiniteFunction({x: 2*x for x in range(5)}, codomain=range(0, 9))
addone = FiniteFunction({x: x+1 for x in range(0, 9)}, codomain=range(1, 10))
comp = addone.compose(double)
comp.report("(x ↦ x+1) ∘ (x ↦ 2x)")
print(f"  comp(3) = {comp(3)}  (আশা করি 7)")

# ── বাস্তব encoding যাচাই ───────────────────────────────────
import base64, itertools

print("\n── base64, সব 2-byte input-এ " + "─" * 20)
pairs = {bytes([a, b]): base64.b64encode(bytes([a, b])).decode()
         for a, b in itertools.product(range(256), repeat=2)}
b64 = FiniteFunction(pairs)
print(f"  {len(b64.domain)} টা input → {len(b64.range)} টা output")
print(f"  injective : {b64.is_injective()}   ← lossless হতে হলে অপরিহার্য")

print("\n── UTF-8, প্রথম 2000 code point-এ " + "─" * 12)
enc = {cp: chr(cp).encode('utf-8') for cp in range(1, 2000)}
u8 = FiniteFunction(enc)
print(f"  injective : {u8.is_injective()}")

print("\n── lower(), ASCII-তে " + "─" * 24)
low = FiniteFunction({chr(c): chr(c).lower() for c in range(65, 123)})
low.report("str.lower()")

শেষ উদাহরণটা গুরুত্বপূর্ণ: lower() injective নয়'A' আর 'a' দুটোই 'a' হয়। তাই lowercase করা একটা তথ্য-হারানো operation, আর সেটা উল্টানো যায় না।

এই কারণেই case-insensitive comparison-এ মূল string রেখে দিতে হয় — শুধু normalized রূপ রাখলে আসল বানানটা হারিয়ে যায়।

নিজে বাড়ান:

  1. is_involution() যোগ করুন — f(f(x)) == x কি না (যেমন ROT13, bitwise NOT, list reverse)
  2. image_of(subset) আর preimage_of(subset) লিখুন
  3. দেখান যে f ∘ g injective হলে g অবশ্যই injective (কিন্তু f নাও হতে পারে)
  4. একটা hash function দিয়ে FiniteFunction বানিয়ে তার collision তালিকা দেখুন
  5. str.upper() আর str.lower() compose করে দেখুন — upper(lower(x)) == upper(x)? কোন অক্ষরে ভাঙে? (ইঙ্গিত: জার্মান ß, তুর্কি ı)

বাস্তব সিস্টেমে

Function-এর ধর্ম যেখানে সিদ্ধান্ত নেয়

Database primary key। একটা key-কে record-এ map করা function injective হতেই হবে — নাহলে একটা key দুইটা row-কে চিহ্নিত করত। UNIQUE constraint আক্ষরিকভাবে injectivity enforce করে।

Serialization। serialize/deserialize জোড়াটা bijection হতে হবে, নাহলে round-trip-এ ডেটা বদলে যায়। এটাই property-based testing-এর সবচেয়ে সাধারণ property:

@given(st.builds(MyRecord))
def test_roundtrip(r):
    assert deserialize(serialize(r)) == r

Cryptographic hash। ইচ্ছাকৃতভাবে non-injective (collision আছে), কিন্তু one-way হতে হবে — h(x) থেকে x বের করা গণনাগতভাবে অবাস্তব। আর preimage resistance মানে: y দিলে এমন x খুঁজে বের করা কঠিন যে h(x) = y

Encryption। প্রতিটা key-এর জন্য bijection (permutation)। Bijection না হলে decrypt অসম্ভব।

URL shortener। long_url → short_code map। Injective না হলে দুইটা ভিন্ন URL একই short code পেত। বাস্তবে database-এ একটা bijection বজায় রাখা হয়।

ORM identity map। একটা database row আর একটা in-memory object-এর মধ্যে bijection বজায় রাখা — নাহলে একই row-এর দুইটা object তৈরি হয়ে inconsistency আসে।

Compiler-এর name mangling। C++-এ overloaded function-দের আলাদা symbol name দিতে হয়। mangle : (name, signature) → symbol injective হতেই হবে, নাহলে linker ভুল function জুড়ে দিত।

echo 'void f(int); void f(double);' | g++ -x c++ -S -o- - | grep _Z
# _Z1fi   ← f(int)
# _Z1fd   ← f(double)

Content-addressed storage। Git, IPFS, Docker layer — সবাই content-এর hash কে address হিসেবে ব্যবহার করে। এটা কাজ করে ধরে নিয়ে যে collision ঘটবে না। Git SHA-1 থেকে SHA-256-এ সরছে ঠিক এই কারণে — SHA-1-এ practical collision পাওয়া গেছে (SHAttered, ২০১৭)।

Load balancer-এর consistent hashing। key → server map। Server যোগ/বিয়োগ হলে যত কম key remap হয় তত ভালো — সাধারণ modulo hashing-এ প্রায় সব key সরে যায়, consistent hashing-এ শুধু 1/n অংশ। Level 9-এ দেখব।

যে ভুলগুলো সবাই করে

“প্রতিটা function-এর একটা inverse আছে, শুধু বের করতে হয়।”

না — inverse থাকে কেবল বijective function-এর

f(x) = x² (বাস্তব সংখ্যায়) — injective নয় (f(2) = f(−2) = 4)। তাই f⁻¹(4) অস্পষ্ট: 2 না −2?

সমাধান দুইভাবে:

১. Domain সংকুচিত করুনf : ℝ⁺ → ℝ⁺ নিলে injective হয়, তখন সংজ্ঞায়িত।

২. Codomain সংকুচিত করুন — surjective না হলে range-কে codomain বানান।

math.sqrt() ঠিক এটাই করে — শুধু অ-ঋণাত্মক মূল ফেরত দেয়। এটা -এর “the inverse” নয়, বরং সীমাবদ্ধ domain-এর উপর একটা inverse।

প্রোগ্রামিং-এ পরিণতি: একটা transformation invertible কি না জানা থাকলে আপনি জানেন মূল ডেটা রেখে দিতে হবে কি না। lower(), round(), trim(), lossy compression — সবগুলোই non-injective, তাই মূল ডেটা হারালে ফেরত পাবেন না।

“ভালো hash function মানে collision-মুক্ত।”

Collision-মুক্ত hash গাণিতিকভাবে অসম্ভব যখন domain codomain-এর চেয়ে বড় — আর সেটা সবসময়ই তাই।

“ভালো” hash-এর অর্থ ভিন্ন, আর সেটা প্রসঙ্গভেদে বদলায়:

প্রসঙ্গ“ভালো” মানে
Hash tableসমান বণ্টন, দ্রুত গণনা
Cryptographiccollision খুঁজে পাওয়া গণনাগতভাবে অবাস্তব
Checksumসাধারণ ত্রুটি (bit flip, transposition) ধরা পড়ে
Perceptual hashএকই রকম input একই hash পায় (ইচ্ছাকৃত collision!)

শেষ সারিটা লক্ষ্য করুন — perceptual hash-এ collision কাম্য। সামান্য বদলানো একই ছবি একই hash পাক, সেটাই লক্ষ্য।

একটা ব্যতিক্রম আছে: perfect hash function — যদি key-set আগে থেকে জানা ও স্থির থাকে, তাহলে সেই নির্দিষ্ট set-এর উপর collision-মুক্ত function বানানো যায়। Compiler-এর keyword lookup-এ এটা ব্যবহার হয় (gperf tool)। কিন্তু এটা কাজ করে কারণ domain কৃত্রিমভাবে ছোট করা হয়েছে।

“Codomain আর range একই জিনিস, নাম আলাদা।”

আলাদা, আর পার্থক্যটা type system-এ সরাসরি দেখা যায়।

def parse_age(s: str) -> int:
    ...
  • Codomain: int — type যা বলছে
  • Range: বাস্তবে হয়তো 0..150

Type checker শুধু codomain জানে। তাই এই কোড type-correct:

age = parse_age(s)
array[age]           # কিন্তু age যদি -1 হয়?

Range-কে type-এ তোলার উপায়:

from typing import NewType
Age = NewType('Age', int)          # nominal typing — সামান্য সাহায্য

# বা runtime validation সহ
from pydantic import conint
Age = conint(ge=0, le=150)

কিছু ভাষায় আরো এগিয়ে যাওয়া যায় — refinement type বা dependent type:

{ n : Int | 0 <= n && n <= 150 }

Liquid Haskell, F*, Dafny, Idris — এগুলোতে range-কে type-এ প্রকাশ করা যায়, আর compiler প্রমাণ করে যে function সেই সীমা ছাড়ায় না।

Level 5 আর Level 13-এ আমরা এই ধারণাগুলো দেখব।

“`f ∘ g` আর `g ∘ f` — ক্রমটা লেখার শৈলীর ব্যাপার।”

ক্রম অর্থ বদলে দেয়, আর ভুল ক্রমে নিরাপত্তা bug হতে পারে।

sanitize(escape(user_input))    #  ?
escape(sanitize(user_input))    #  ?

দুটো আলাদা ফল দিতে পারে। ধরুন sanitize কিছু অক্ষর মুছে দেয় আর escape কিছু অক্ষরকে entity-তে বদলায়:

input:  \<script>
escape → &lt;script&gt;
sanitize → &lt;script&gt;          কিছু মুছল না, নিরাপদ

input:  \<scr\<script>ipt>
sanitize → \<script>              ভেতরেরটা মুছে বাইরেরটা জোড়া লাগল!
escape → &lt;script&gt;           এখনো নিরাপদ, কিন্তু...

আসল বিপদ যখন ক্রম উল্টো:

sanitize(escape(x))   — escape আগে, তাই sanitize-এর জন্য কিছু বাকি নেই ✓
escape(sanitize(x))   — sanitize নতুন pattern তৈরি করতে পারে ✗

এটাই mutation XSS বা mXSS-এর একটা রূপ — sanitizer-এর নিজের output আবার parse হলে নতুন attack vector তৈরি হয়।

(g ∘ f)⁻¹ = f⁻¹ ∘ g⁻¹ — inverse-এর ক্রম উল্টো, এটাও ভুলবেন না। Encrypt-then-compress আর compress-then-encrypt সম্পূর্ণ ভিন্ন নিরাপত্তা বৈশিষ্ট্য দেয়।

বুঝেছেন কি না দেখুন

1

f : ℤ → ℤ, f(x) = 2x। এটা injective? surjective? bijective? এখন f : ℤ → 2ℤ (জোড় পূর্ণসংখ্যা) হলে উত্তর বদলায় কি?

প্রয়োগ

f : ℤ → ℤ ক্ষেত্রে:

Injective — হ্যাঁ। 2a = 2b হলে a = b (২ দিয়ে ভাগ করে)।

Surjective — না। 3 কোনো x-এর জন্য 2x হতে পারে না (x = 1.5 পূর্ণসংখ্যা নয়)। বিজোড় সংখ্যাগুলো কেউ hit করে না।

Bijective — না।

f : ℤ → 2ℤ ক্ষেত্রে:

Codomain বদলে জোড় সংখ্যা করা হয়েছে। এখন:

Injective — হ্যাঁ (একই যুক্তি)। Surjective — হ্যাঁ। যেকোনো জোড় সংখ্যা 2k-এর জন্য x = k আছে। Bijective — হ্যাঁ, আর f⁻¹(y) = y/2

একই সূত্র, ভিন্ন codomain, ভিন্ন উত্তর।

একটা মজার ফল: এই bijection প্রমাণ করে |ℤ| = |2ℤ| — পূর্ণসংখ্যা আর জোড় পূর্ণসংখ্যার “সংখ্যা” সমান, যদিও জোড়গুলো ℤ-এর একটা proper subset!

অসীম set-এ এটা স্বাভাবিক — একটা set তার নিজের proper subset-এর সাথে bijection-এ থাকতে পারে। আসলে এটাই অসীমের সংজ্ঞা (Dedekind-এর সংজ্ঞা)।

Hilbert’s Hotel-এর গল্পটা এই ধারণার জনপ্রিয় রূপ: অসীম কামরার হোটেল পূর্ণ থাকলেও নতুন অতিথিকে জায়গা দেওয়া যায় — সবাইকে এক কামরা সরিয়ে দিলেই হলো।

প্রোগ্রামিং-এ পাঠ: type signature-এ codomain লেখা থাকে, কিন্তু আসল range আলাদা হতে পারে। -> int লেখা থাকলেও function হয়তো শুধু জোড় সংখ্যা ফেরত দেয় — আর সেই তথ্যটা type-এ নেই।

2

প্রমাণ করুন: g ∘ f injective হলে f অবশ্যই injective। কিন্তু g injective না হতেও পারে — একটা উদাহরণ দিন।

যুক্তি

অংশ ১ — f অবশ্যই injective।

Contrapositive প্রমাণ করি: f injective না হলে g ∘ f injective নয়।

f injective না হলে এমন a₁ ≠ a₂ আছে যে f(a₁) = f(a₂)

তাহলে: (gf)(a1)=g(f(a1))=g(f(a2))=(gf)(a2)(g \circ f)(a_1) = g(f(a_1)) = g(f(a_2)) = (g \circ f)(a_2)

দুইটা ভিন্ন input, একই output — তাই g ∘ f injective নয়। ∎

অংশ ২ — g injective না হয়েও চলে।

A = {1, 2}        B = {a, b, c}      C = {x, y}

f: 1 → a          g: a → x
   2 → b             b → y
                     c → y      ← g injective নয় (b, c দুটোই y-তে)

g ∘ f: 1 → x
       2 → y      ← injective ✓

g injective নয় (g(b) = g(c) = y), কিন্তু g ∘ f injective — কারণ c কখনো f-এর range-এ আসে না, তাই সেই collision-টা কখনো প্রকাশ পায় না।

মূল অন্তর্দৃষ্টি: g-কে শুধু f-এর range-এর উপর injective হতে হবে, পুরো domain-এর উপর নয়।

বাস্তব উদাহরণ:

def f(user_id: int) -> str:
    return f"user:{user_id}"          # injective

def g(key: str) -> int:
    return hash(key) % (2**32)        # injective নয় — collision আছে

g ∘ f বাস্তবে injective হতে পারে যদি আপনার সব user id-এর জন্য কোনো collision না ঘটে — যদিও g সাধারণভাবে injective নয়।

এটাই hash table কাজ করার কারণ: hash function সাধারণভাবে non-injective, কিন্তু আপনার নির্দিষ্ট key set-এর উপর প্রায় injective হতে পারে।

আর এটাই perfect hashing-এর ধারণা: key set জানা থাকলে এমন g বেছে নিন যা ঠিক সেই set-এর উপর injective।

3

আপনি একটা URL shortener বানাচ্ছেন। shorten : URL → Code। কোন ধর্মগুলো দরকার, আর কোনটা দরকার নেই? Code-এর দৈর্ঘ্য কীভাবে ঠিক করবেন?

ডিজাইন

যা দরকার — expand অবশ্যই একটা function হতে হবে।

অর্থাৎ প্রতিটা code ঠিক একটা URL-এ যাবে। এটা না হলে সিস্টেম অর্থহীন — একটা short link দুই জায়গায় নিয়ে যেতে পারে না।

সমতুল্যভাবে: shorten injective হতে হবে — দুইটা ভিন্ন URL কখনো একই code পাবে না।

যা দরকার নেই — surjective।

সব সম্ভাব্য code ব্যবহৃত হওয়ার দরকার নেই। বেশিরভাগ code খালি থাকবে, আর সেটাই স্বাভাবিক।

একটা নকশাগত পছন্দ — shorten কি deterministic হবে?

পদ্ধতিআচরণসুবিধাঅসুবিধা
Hash-ভিত্তিকএকই URL → একই codestorage কম, idempotentcollision সামলাতে হয়; সবাই একই code পায় (privacy)
Counter-ভিত্তিকপ্রতিবার নতুন codecollision নেই, ছোট codeকেন্দ্রীয় counter (distributed-এ কঠিন); code অনুমানযোগ্য
Randomপ্রতিবার নতুনঅনুমান করা কঠিনcollision যাচাই করতে হয়

দৈর্ঘ্য নির্ধারণ — birthday bound দিয়ে।

Alphabet ৬২টা অক্ষর (a-zA-Z0-9)। n অক্ষরের code মানে 62ⁿ টা সম্ভাবনা।

Random code ব্যবহার করলে, k টা code বরাদ্দের পর collision-এর সম্ভাবনা প্রায়:

P1ek2/(262n)P \approx 1 - e^{-k^2 / (2 \cdot 62^n)}

nমোট code১% collision ঝুঁকিতে সর্বোচ্চ URL
59.2 × 10⁸~136,000
65.7 × 10¹⁰~1.07 million
73.5 × 10¹²~8.4 million
82.2 × 10¹⁴~66 million

সিদ্ধান্ত: ১০০ মিলিয়ন URL আশা করলে ৮ অক্ষর নিন, আর তার সাথে retry on collision রাখুন:

def shorten(url, max_tries=5):
    for _ in range(max_tries):
        code = random_code(8)
        if db.insert_if_absent(code, url):    # atomic
            return code
    raise RuntimeError("collision retry শেষ")

insert_if_absent atomic হতেই হবে — নাহলে দুইটা concurrent request একই code পেয়ে যেতে পারে (race condition)।

একটা অতিরিক্ত বিবেচনা — অনুমানযোগ্যতা।

Counter-ভিত্তিক code (1, 2, 3, … কে base62-তে) ছোট আর সুন্দর, কিন্তু কেউ পরপর code চেষ্টা করে সব link দেখে ফেলতে পারে। Private link-এর জন্য random অপরিহার্য।

মাঝামাঝি সমাধান: counter নিন, কিন্তু একটা bijective obfuscation প্রয়োগ করুন (যেমন Feistel network বা multiplicative inverse mod 62ⁿ)। তখন code এলোমেলো দেখায় কিন্তু collision গাণিতিকভাবে অসম্ভব — কারণ transformation-টা bijection।

Level 10-এ আমরা Feistel network দেখব।

4

f(x) = x % 256 — এই function কি injective? এটা কোন ধরনের bug তৈরি করতে পারে?

প্রয়োগ

Injective নয় (domain যদি 256-এর চেয়ে বড় হয়)।

f(0) = f(256) = f(512) = … = 0

Domain হলে প্রতিটা output-এ অসীম সংখ্যক input map হয়।

যেসব bug এটা তৈরি করে:

১. Integer truncation।

int  big  = 300;
char small = big;        // 300 % 256 = 44
printf("%d\n", small);   // 44

char -এ ৩০০ আঁটে না। তথ্য হারায় নীরবে — কোনো warning ছাড়া (-Wconversion না দিলে)।

২. Buffer size overflow।

uint8_t len = strlen(input);      // input যদি 300 byte হয়?
char buf[256];
memcpy(buf, input, len);          // len = 44, তাই এখানে নিরাপদ
// কিন্তু পরে কেউ যদি strlen(input) আবার ব্যবহার করে — mismatch

আরো খারাপ ক্ষেত্রে:

uint8_t n = user_count;           // 256 → 0
if (n > 0) { allocate(n); }       // কখনো চলবে না, বা 0 byte allocate

৩. Hash table-এ modulo bias।

bucket = hash(key) % num_buckets

num_buckets যদি 256 হয় আর hash function-এর low bit দুর্বল হয়, তাহলে শুধু low 8 bit ব্যবহার হচ্ছে — উপরের সব bit-এর তথ্য ফেলে দেওয়া হচ্ছে।

এই কারণেই ভালো hash table implementation prime সংখ্যক bucket ব্যবহার করে, বা hash-কে আগে “mix” করে (Java-র HashMap উপরের bit-গুলো নিচে XOR করে দেয়)।

৪. Random সংখ্যায় modulo bias।

int roll = rand() % 6;      // 0..5 চাই

RAND_MAX যদি 6-এর গুণিতক না হয়, তাহলে ছোট মানগুলো সামান্য বেশি সম্ভাব্য হয়। Cryptographic প্রসঙ্গে এটা গুরুতর।

সঠিক পদ্ধতি — rejection sampling:

int r;
do { r = rand(); } while (r >= RAND_MAX - RAND_MAX % 6);
return r % 6;

৫. Sequence number wraparound।

TCP-র sequence number ৩২ bit, তাই 2³² byte পর wrap করে। উচ্চ-গতির লিঙ্কে এটা সেকেন্ডে ঘটতে পারে — তাই TCP-তে PAWS (Protection Against Wrapped Sequence numbers) নামে একটা প্রক্রিয়া আছে। Level 7-এ দেখব।

মূল পাঠ: % একটা non-injective function। প্রতিবার % লেখার মানে আপনি ইচ্ছাকৃতভাবে তথ্য ফেলে দিচ্ছেন — নিশ্চিত হোন যে সেটাই আপনি চান।

Level 1-এ আমরা integer overflow আর wraparound bit ধরে দেখব।

5

কেন sort একটা function কিন্তু shuffle নয়? আর কেন এই পার্থক্যটা testing-এ গুরুত্বপূর্ণ?

যুক্তি

sort একটা function — একই input list-এ সবসময় একই output। Deterministic, side-effect-মুক্ত (যদি copy ফেরত দেয়)।

shuffle function নয় — একই input-এ ভিন্ন output। এটা determinism শর্ত ভাঙে।

গাণিতিকভাবে shuffle একটা function হতে পারে যদি random state-কেও input ধরা হয়:

shuffle:(List,Seed)List\text{shuffle} : (\text{List}, \text{Seed}) \to \text{List}

এখন এটা deterministic — একই seed, একই ফল।

Testing-এ কেন গুরুত্বপূর্ণ:

১. Function-দের সহজে test করা যায়।

def test_sort():
    assert sort([3,1,2]) == [1,2,3]      # সরাসরি তুলনা

২. Non-function-দের property দিয়ে test করতে হয়।

def test_shuffle():
    original = [1,2,3,4,5]
    result = shuffle(original[:])
    assert sorted(result) == sorted(original)   # permutation
    assert len(result) == len(original)
    # কিন্তু ঠিক কী হবে তা বলা যায় না

৩. Seed ঢুকিয়ে reproducible করা যায়।

def test_shuffle_deterministic():
    rng = random.Random(42)
    a = shuffle([1,2,3,4,5], rng=random.Random(42))
    b = shuffle([1,2,3,4,5], rng=random.Random(42))
    assert a == b            # এখন function-এর মতো আচরণ

এই কারণেই ভালো library-তে RNG inject করা যায়, global state ব্যবহার করে না। numpy.random.Generator, Rust-এর rand::Rng trait — সবাই এই নকশা অনুসরণ করে।

৪. একই যুক্তি সব impure function-এ প্রযোজ্য।

Impure নির্ভরতাTest-এর জন্য কী করবেন
Randomseed inject করুন
বর্তমান সময়clock inject করুন
NetworkHTTP client inject করুন
Filesystemabstraction inject করুন
Environmentconfig object পাস করুন

এই সবগুলো একই কৌশল: লুকানো input-কে স্পষ্ট parameter বানান। তখন non-function টা function হয়ে যায়, আর testable হয়ে যায়।

Haskell-এ এটা type-এ বাধ্যতামূলক — IO type-এ না থাকলে function pure হতেই হবে। Level 5-এ আমরা দেখব এই সীমাবদ্ধতা কীভাবে compiler-কে আরো আক্রমণাত্মক optimization করতে দেয়: pure function memoize করা যায়, পুনর্বিন্যাস করা যায়, সমান্তরালে চালানো যায় — কারণ কোনো লুকানো নির্ভরতা নেই।

এরপর কী

Function শিখে আমরা এখন “কতগুলো” প্রশ্নের জন্য প্রস্তুত।

n bit-এ কতগুলো ভিন্ন মান? একটা password কত রকম হতে পারে? একটা hash table-এ collision-এর সম্ভাবনা কত? একটা algorithm কতবার loop চালাবে?

পরের লেসনে combinatorics — গোনার শিল্প। Product rule, permutation, combination, inclusion-exclusion। এগুলো ছাড়া complexity analysis করা যায় না, আর cryptographic key space হিসাব করা যায় না।

তারপর probability — যেখানে আমরা দেখব birthday paradox কীভাবে ৬৫,৫৩৬টা input-এই ৩২-bit hash-এ collision আনে, আর কেন randomized algorithm প্রায়ই deterministic-এর চেয়ে ভালো।

আরও পড়ুন

  • Discrete Mathematics and Its Applications, §2.3 — Kenneth Rosen
  • Category Theory for Programmers — Bartosz Milewski · Function composition-কে আরো গভীরে দেখতে চাইলে