Functions — injection, surjection আর তথ্য হারানোর গণিত
Functions and Their Properties
একটা function কখন উল্টানো যায় আর কখন যায় না — এই একটা প্রশ্নের উত্তর ব্যাখ্যা করে hash collision, compression-এর সীমা, encoding আর lossy/lossless-এর পার্থক্য।
আগে এটা বুঝি
তিনটা প্রশ্ন, একটাই গাণিতিক উত্তর:
- “আমার 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 যদি:
∃! মানে “ঠিক একটা আছে”। দুইটা শর্ত এতে লুকানো:
- Total — প্রতিটা
a-এর জন্য অন্তত একটাbআছে - Deterministic — সর্বোচ্চ একটা
bআছে
Function ✓ Function নয় (total নয়) Function নয় (একাধিক output)
a ──→ 1 a ──→ 1 a ──→ 1
b ──→ 2 b │└─→ 2
c ──→ 1 c ──→ 2 b ──→ 2তিনটা শব্দ আলাদা করে মনে রাখুন:
| শব্দ | মানে |
|---|---|
| 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) — কিছু হারায় না
সমতুল্যভাবে (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 ব্যবহৃত হয়
অর্থাৎ 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 থাকে।
ভেতরে কী ঘটছে
Injectivity ভাঙলে যা হয়
এখানেই তত্ত্ব বাস্তবে পরিণত হয়। তিনটা পরিণতি, তিনটাই অনিবার্য।
পরিণতি ১: Hash collision অনিবার্য
একটা hash function:
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.gzRandom ডেটা 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।
- Injective?মূল প্রশ্ন
- হ্যাঁ → invertibleencoding, encryption, lossless compression
- base64, UTF-8, AES, gzipমূল ডেটা ফেরত পাওয়া যায়
- না → তথ্য হারায়দুই রকম
- দুর্ঘটনাক্রমে → hash, checksumcollision সহ্য করতে হয়
- ইচ্ছাকৃতভাবে → JPEG, MP3অনুভূত মানের বিনিময়ে আকার
Composition
দুইটা function জোড়া লাগানো:
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 identityInjectivity ও surjectivity composition-এ কীভাবে চলে:
| যদি | তাহলে g ∘ f |
|---|---|
f, g দুটোই injective | injective |
f, g দুটোই surjective | surjective |
f, g দুটোই bijective | bijective, আর (g∘f)⁻¹ = f⁻¹ ∘ g⁻¹ |
শেষ সূত্রটার ক্রম লক্ষ্য করুন — উল্টো। মোজা তারপর জুতো পরলে, খুলতে হয় জুতো তারপর মোজা।
কোডে এটা প্রতিদিন:
# encode: compress তারপর encrypt
blob = encrypt(compress(data))
# decode: উল্টো ক্রমে
data = decompress(decrypt(blob))Pigeonhole-এর function রূপ
গত লেসনে যা counting argument হিসেবে দেখেছি, এখন function-এর ভাষায়:
Theorem:
|A| > |B|হলে কোনো functionf : A → Binjective নয়।
প্রমাণ (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 hbad_hash-এ "abc", "acb", "bca" — সবার hash একই, কারণ যোগ
commutative। যেকোনো anagram collide করে।
better_hash-এ position গুরুত্বপূর্ণ, কারণ 31-এর ভিন্ন ঘাত দিয়ে
গুণ হচ্ছে।
কেন ঠিক ৩১? তিনটা কারণ:
- মৌলিক সংখ্যা — modulo নেওয়ার সময় pattern কম তৈরি হয়
- বিজোড় — জোড় হলে প্রতি গুণে একটা করে low bit হারাত
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 byteInjective — প্রতিটা code point-এর একটাই encoding, আর decode
অস্পষ্টতাহীন। এটা UTF-8-এর নকশার একটা মূল লক্ষ্য ছিল, আর সেজন্যই
continuation byte-গুলো 10xxxxxx দিয়ে শুরু হয় — মাঝখান থেকে পড়া
শুরু করলেও সীমানা চেনা যায়।
Level 1-এ আমরা UTF-8 encoder নিজে লিখব।
Encryption — key ঠিক থাকলে bijection:
E_k : {0,1}ⁿ → {0,1}ⁿ প্রতিটা k-এর জন্য একটা permutationBlock cipher আক্ষরিকভাবে একটা permutation — n-bit block space-এর
উপর একটা bijection। Bijection না হলে decrypt করা যেত না।
AES-128-এ block size ১২৮ bit, তাই এটা 2¹²⁸ element-এর একটা
set-এর উপর একটা permutation, আর key নির্ধারণ করে কোন permutation।
নিজে চালিয়ে দেখুন
Hash distribution মেপে দেখুন
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.38bad_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)-এর মধ্যে ব্যবধান।
Compression-এর সীমা নিজে দেখুন
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 ডেটায় সেটা সরাসরি দেখা যায়।
নিজে বানান
Function Property Checker
- সসীম domain-এ একটা function-এর injective/surjective/bijective যাচাই করুন
- Bijective হলে inverse বানান
- Composition করুন এবং ধর্মগুলো কীভাবে চলে দেখুন
- বাস্তব 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 রূপ রাখলে আসল বানানটা হারিয়ে যায়।
নিজে বাড়ান:
is_involution()যোগ করুন —f(f(x)) == xকি না (যেমন ROT13, bitwise NOT, list reverse)image_of(subset)আরpreimage_of(subset)লিখুন- দেখান যে
f ∘ ginjective হলেgঅবশ্যই injective (কিন্তুfনাও হতে পারে) - একটা hash function দিয়ে
FiniteFunctionবানিয়ে তার collision তালিকা দেখুন 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)) == rCryptographic 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() ঠিক এটাই করে — শুধু অ-ঋণাত্মক মূল ফেরত দেয়। এটা
x²-এর “the inverse” নয়, বরং সীমাবদ্ধ domain-এর উপর একটা inverse।
প্রোগ্রামিং-এ পরিণতি: একটা transformation invertible কি না
জানা থাকলে আপনি জানেন মূল ডেটা রেখে দিতে হবে কি না। lower(),
round(), trim(), lossy compression — সবগুলোই non-injective,
তাই মূল ডেটা হারালে ফেরত পাবেন না।
“ভালো hash function মানে collision-মুক্ত।”
Collision-মুক্ত hash গাণিতিকভাবে অসম্ভব যখন domain codomain-এর চেয়ে বড় — আর সেটা সবসময়ই তাই।
“ভালো” hash-এর অর্থ ভিন্ন, আর সেটা প্রসঙ্গভেদে বদলায়:
| প্রসঙ্গ | “ভালো” মানে |
|---|---|
| Hash table | সমান বণ্টন, দ্রুত গণনা |
| Cryptographic | collision খুঁজে পাওয়া গণনাগতভাবে অবাস্তব |
| 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 → <script>
sanitize → <script> কিছু মুছল না, নিরাপদ
input: \<scr\<script>ipt>
sanitize → \<script> ভেতরেরটা মুছে বাইরেরটা জোড়া লাগল!
escape → <script> এখনো নিরাপদ, কিন্তু...আসল বিপদ যখন ক্রম উল্টো:
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 সম্পূর্ণ ভিন্ন
নিরাপত্তা বৈশিষ্ট্য দেয়।
বুঝেছেন কি না দেখুন
1f : ℤ → ℤ, f(x) = 2x। এটা injective? surjective? bijective?
এখন f : ℤ → 2ℤ (জোড় পূর্ণসংখ্যা) হলে উত্তর বদলায় কি?
প্রয়োগ
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 না হতেও পারে — একটা উদাহরণ দিন।
যুক্তি
g ∘ f injective হলে f অবশ্যই injective। কিন্তু g
injective না হতেও পারে — একটা উদাহরণ দিন।অংশ ১ — f অবশ্যই injective।
Contrapositive প্রমাণ করি: f injective না হলে g ∘ f injective নয়।
f injective না হলে এমন a₁ ≠ a₂ আছে যে f(a₁) = f(a₂)।
তাহলে:
দুইটা ভিন্ন 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-এর দৈর্ঘ্য কীভাবে
ঠিক করবেন?
ডিজাইন
shorten : URL → Code।
কোন ধর্মগুলো দরকার, আর কোনটা দরকার নেই? Code-এর দৈর্ঘ্য কীভাবে
ঠিক করবেন?যা দরকার — expand অবশ্যই একটা function হতে হবে।
অর্থাৎ প্রতিটা code ঠিক একটা URL-এ যাবে। এটা না হলে সিস্টেম অর্থহীন — একটা short link দুই জায়গায় নিয়ে যেতে পারে না।
সমতুল্যভাবে: shorten injective হতে হবে — দুইটা ভিন্ন URL
কখনো একই code পাবে না।
যা দরকার নেই — surjective।
সব সম্ভাব্য code ব্যবহৃত হওয়ার দরকার নেই। বেশিরভাগ code খালি থাকবে, আর সেটাই স্বাভাবিক।
একটা নকশাগত পছন্দ — shorten কি deterministic হবে?
| পদ্ধতি | আচরণ | সুবিধা | অসুবিধা |
|---|---|---|---|
| Hash-ভিত্তিক | একই URL → একই code | storage কম, idempotent | collision সামলাতে হয়; সবাই একই code পায় (privacy) |
| Counter-ভিত্তিক | প্রতিবার নতুন code | collision নেই, ছোট code | কেন্দ্রীয় counter (distributed-এ কঠিন); code অনুমানযোগ্য |
| Random | প্রতিবার নতুন | অনুমান করা কঠিন | collision যাচাই করতে হয় |
দৈর্ঘ্য নির্ধারণ — birthday bound দিয়ে।
Alphabet ৬২টা অক্ষর (a-zA-Z0-9)। n অক্ষরের code মানে 62ⁿ টা
সম্ভাবনা।
Random code ব্যবহার করলে, k টা code বরাদ্দের পর collision-এর
সম্ভাবনা প্রায়:
n | মোট code | ১% collision ঝুঁকিতে সর্বোচ্চ URL |
|---|---|---|
| 5 | 9.2 × 10⁸ | ~136,000 |
| 6 | 5.7 × 10¹⁰ | ~1.07 million |
| 7 | 3.5 × 10¹² | ~8.4 million |
| 8 | 2.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 দেখব।
4f(x) = x % 256 — এই function কি injective? এটা কোন ধরনের bug
তৈরি করতে পারে?
প্রয়োগ
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); // 44char -এ ৩০০ আঁটে না। তথ্য হারায় নীরবে — কোনো 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_bucketsnum_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 কিন্তু shuffle নয়? আর কেন এই পার্থক্যটা
testing-এ গুরুত্বপূর্ণ?sort একটা function — একই input list-এ সবসময় একই output।
Deterministic, side-effect-মুক্ত (যদি copy ফেরত দেয়)।
shuffle function নয় — একই input-এ ভিন্ন output। এটা
determinism শর্ত ভাঙে।
গাণিতিকভাবে shuffle একটা function হতে পারে যদি random state-কেও
input ধরা হয়:
এখন এটা 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-এর জন্য কী করবেন |
|---|---|
| Random | seed inject করুন |
| বর্তমান সময় | clock inject করুন |
| Network | HTTP client inject করুন |
| Filesystem | abstraction inject করুন |
| Environment | config 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-কে আরো গভীরে দেখতে চাইলে