সরাসরি পর্বে যান
পর্ব ৩৩ · ৭২-এর মধ্যে · মডিউল ৪

Disjoint set union (union-find)

Disjoint Set Union (Union-Find) — Parent-Pointer Forests, Union by Size/Rank, Path Compression, Inverse Ackermann & Parity DSU

Taught in Bangla, this lesson develops disjoint set union from label arrays and linked lists to parent-pointer forests, union by size and by rank, path compression, the nearly constant inverse Ackermann bound, applications from Kruskal's algorithm to connected regions in images, and parity DSU for enemy-of-my-enemy constraints, with C++ and Python code.

এই পর্বে যা শিখবেন

  • Dynamic connectivity সমস্যা চেনা, আর make-set, find, union — এই তিন কাজের ADT হিসেবে DSU বোঝা
  • লেবেলের অ্যারে আর linked list থেকে শুরু করে parent pointer-এর forest পর্যন্ত বিবর্তনটি ধরতে পারা
  • Union by size/rank-এ উচ্চতা ≤ log₂ n-এর প্রমাণ লেখা, আর path compression, halving ও splitting হাতে trace করা
  • O(α(n)) amortized সীমাটির অর্থ বোঝা — কী বলে আর কী বলে না
  • Kruskal, cycle detection, component গোনা, offline প্রশ্ন আর parity DSU-তে নিজে DSU বসানো, C++ ও Python-এ

১ · সমস্যা — বন্যার পর কোন গ্রাম কোন গ্রামের সাথে যুক্ত?

সিলেটের একটি উপজেলায় বড় বন্যা হয়েছে। পানি নামার পর দেখা গেল সব রাস্তা ভেঙে গেছে, আটটি গ্রাম একে অন্যের থেকে বিচ্ছিন্ন। উপজেলা অফিস একটি একটি করে রাস্তা মেরামত করছে, আর প্রতিদিন খবর আসছে “আজ ২ নম্বর আর ৩ নম্বর গ্রামের মধ্যের রাস্তা খুলেছে”. এর মধ্যেই ত্রাণের গাড়ির চালক জানতে চান: “আমি কি ০ নম্বর গ্রাম থেকে ৬ নম্বর গ্রামে যেতে পারব — সরাসরি রাস্তা না থাকলেও অন্য গ্রাম ঘুরে?”

এখানে দুই ধরনের ঘটনা মিশে আসছে: নতুন সংযোগ (রাস্তা খুলল) আর প্রশ্ন (দুটি গ্রাম কি যুক্ত?). পর্ব ৩৯-এর ভাষায় গ্রামগুলো একটি graph-এর vertex, রাস্তাগুলো edge. যে গ্রামগুলোর মধ্যে কোনো না কোনো পথে যাওয়া যায়, তারা মিলে একটি connected component. সংযোগ বাড়তে থাকা অবস্থায় এই প্রশ্নের উত্তর দেওয়ার সমস্যাটির নাম dynamic connectivity.

প্রতিটি প্রশ্নে নতুন করে BFS বা DFS চালানো যায় (পর্ব ৪০–৪১) — কিন্তু তাতে প্রতি প্রশ্নে O(n + m) সময়, যেখানে n গ্রাম আর m রাস্তা। দশ লাখ গ্রাম আর দশ লাখ প্রশ্নে সেটি প্রায় 1012 ধাপ — অচল। আমাদের দরকার এমন একটি ডেটা স্ট্রাকচার, যা প্রতিটি নতুন রাস্তা আর প্রতিটি প্রশ্নকে প্রায় ধ্রুবক সময়ে সামলায়। সেটিই disjoint set union (DSU), যার আরেক নাম union-find.

একই কাঠামো আরও অনেক জায়গায় লুকিয়ে আছে। ফেসবুকে “বন্ধুর বন্ধু” ধরে যারা যুক্ত, তারা একটি friend circle — নতুন বন্ধুত্ব হলে দুটি circle মিশে যায়। একটি অফিসের কম্পিউটার নেটওয়ার্কে কোন কোন মেশিন কেবল দিয়ে একে অন্যের কাছে পৌঁছাতে পারে, সেটিও একই প্রশ্ন। সব ক্ষেত্রে মূল কথা দুটি: উপাদানগুলো কয়েকটি আলাদা, পরস্পর-বিচ্ছিন্ন (disjoint) দলে ভাগ — কেউ একসাথে দুই দলে নেই; আর দলগুলো সময়ের সাথে শুধু জোড়া লাগে.

তিনটি কাজ — DSU-এর ADT

অপারেশনকাজগ্রামের উপমায়
make-set(x)x-কে নিয়ে একটি নতুন এক-সদস্যের দল বানানোবন্যার পর প্রতিটি গ্রাম একা
find(x)x যে দলে আছে, তার প্রতিনিধি (representative) ফেরত দেওয়া“এই গ্রাম কোন অঞ্চলের?” — অঞ্চলের নাম একটি নির্দিষ্ট গ্রামের নামে
union(a, b)a-র দল আর b-র দল মিলিয়ে একটি দল বানানোa আর b-র মধ্যে রাস্তা খুলল
connected(a, b)find(a) == find(b) কি না“এক গ্রাম থেকে অন্যটিতে যাওয়া যায়?”

প্রতিনিধির ধারণাটি সূক্ষ্ম কিন্তু গুরুত্বপূর্ণ। DSU কোনো দলের সব সদস্যের তালিকা দিতে বাধ্য নয়; সে শুধু প্রতিটি দলের জন্য একজন নির্দিষ্ট সদস্যকে প্রতিনিধি রাখে। একই দলের যেকোনো দুজনকে find জিজ্ঞেস করলে একই উত্তর আসবে — যতক্ষণ মাঝে কোনো union না হয়। কে প্রতিনিধি, তা union-এর পর বদলাতে পারে; তাই find-এর উত্তর মনে রেখে পরে ব্যবহার করা ভুল।

গণিতের ভাষায়, “একই দলে আছে” সম্পর্কটি একটি equivalence relation: প্রত্যেকে নিজের সাথে একই দলে (reflexive), a যদি b-র দলে থাকে তো b-ও a-র দলে (symmetric), আর a~b ও b~c হলে a~c (transitive). যেকোনো equivalence relation উপাদানগুলোকে পরস্পর-বিচ্ছিন্ন শ্রেণিতে ভাগ করে — DSU সেই ভাগাভাগিকে ধাপে ধাপে মোটা করতে থাকে। এটিও একটি abstract data type: কাজগুলো ঠিক, কিন্তু ভেতরে কীভাবে রাখা হবে তা খোলা। পরের অংশগুলোতে আমরা চারটি বাস্তবায়ন দেখব, প্রতিটি আগেরটির দুর্বলতা সারিয়ে।

২ · প্রথম চেষ্টা — লেবেলের অ্যারে আর linked list

লেবেলের অ্যারে (quick-find)

সবচেয়ে সরল উপায়: একটি অ্যারে id[x] রাখুন, যাতে লেখা থাকে x কোন দলের। শুরুতে id[x] = x. তখন find(x) মানে শুধু id[x] পড়া — O(1). কিন্তু union(a, b)-তে a-র দলের সবার লেবেল বদলে b-র দলের লেবেল করতে হবে, আর কারা a-র দলে তা জানতে পুরো অ্যারে দেখতে হয় — O(n). এই পদ্ধতির নাম quick-find: খোঁজা দ্রুত, জোড়া ধীর। n টি গ্রামকে একটি দলে আনতে n − 1 টি union লাগে, তাই মোট Θ(n²) — দশ লাখ গ্রামে 1012, আবার সেই অচল অবস্থা।

Pseudocode — quick-find (0-based)

find(x):     return id[x]                        // O(1)
union(a, b): A = id[a];  B = id[b]
             if A == B: return
             for i = 0 to n-1:                   // O(n): scan everyone
                 if id[i] == A: id[i] = B

Linked list আর weighted union — প্রথম চতুর বুদ্ধি

পুরো অ্যারে না ঘুরে শুধু a-র দলের সদস্যদের ছুঁতে চাইলে প্রতিটি দলকে একটি linked list হিসেবে রাখুন (পর্ব ১২): প্রতিটি node-এ একটি pointer থাকে list-এর মাথার (head) দিকে, আর head-ই প্রতিনিধি। find(x) = x-এর head pointer — O(1). union-এ একটি list আরেকটির শেষে জুড়ে দিতে হয়, আর জুড়ে দেওয়া list-এর প্রত্যেক সদস্যের head pointer বদলাতে হয়। খারাপ ক্রমে (প্রতিবার বড় list-টিকে ছোটটির পেছনে জোড়া) এটিও মোট Θ(n²).

সমাধান এক লাইনের: সবসময় ছোট list-টিকে বড়টির পেছনে জুড়ুন, যাতে head pointer বদলাতে হয় কম সদস্যের। একে বলে weighted union. ফলটি বিস্ময়কর, আর যুক্তিটি এই পর্বের মূল যুক্তির বীজ। একটি নির্দিষ্ট উপাদান x-কে দেখুন। x-এর head pointer বদলায় শুধু তখন, যখন x-এর list ছিল ছোটটি (বা সমান) — আর জোড়ার পর নতুন list-এর আকার অন্তত x-এর আগের list-এর দ্বিগুণ. শুরুতে আকার ১; প্রতিবার বদলে অন্তত দ্বিগুণ; আকার কখনো n ছাড়াতে পারে না। তাই x-এর pointer সর্বোচ্চ ⌊log₂ n⌋ বার বদলায়। n টি উপাদান মিলে সব union-এর মোট কাজ O(n log n), আর m টি find আর union মিলিয়ে মোট O(m + n log n). প্রতিটি উপাদানের হিসাবে এটি O(log n) — amortized: কোনো একটি union একাই n/2 টি pointer বদলাতে পারে, কিন্তু সব মিলিয়ে গড় খরচ ছোট।

বাস্তবায়নfindunionm টি কাজ, n টি উপাদান
লেবেলের অ্যারে (quick-find)O(1)O(n)O(mn) worst case
Linked list, যেকোনো ক্রমে জোড়াO(1)O(n)Θ(n²) পর্যন্ত
Linked list + weighted unionO(1)O(log n) amortizedO(m + n log n)
Forest + rank + path compressionO(α(n)) amortizedO(m α(n))

Weighted union-এর “ছোটকে বড়র সাথে জুড়ুন” কৌশলটি কনটেস্টে small-to-large নামে আলাদা পরিচয় পেয়েছে — দুটি set বা map জোড়ার সময় সবসময় ছোটটির উপাদান বড়টিতে ঢোকালে প্রতিটি উপাদান সর্বোচ্চ log n বার সরে। কিন্তু DSU-এর জন্য আমরা আরও ভালো করতে পারি: union-এ কারও pointer বদলানোর দরকারই নেই, যদি পুরো list-এর বদলে শুধু একটি pointer বদলাই।

৩ · Forest — প্রত্যেকের হাতে শুধু parent-এর ঠিকানা

১৯৬৪ সালে Bernard Galler আর Michael Fischer যে ধারণা প্রকাশ করেন, তা আজও DSU-এর ভিত্তি: প্রতিটি দলকে একটি tree হিসেবে রাখুন, যেখানে প্রতিটি node শুধু তার parent-কে চেনে — সন্তানদের নয়। Tree-র root নিজেই নিজের parent, আর সে-ই দলের প্রতিনিধি। সব দল মিলে কয়েকটি tree-র একটি forest. পুরো forest রাখতে একটিই অ্যারে লাগে: parent pointer p[x].

উপমা — অফিসের রিপোর্টিং লাইন। একটি বড় garments কারখানায় প্রত্যেক কর্মী শুধু জানেন তাঁর সরাসরি সুপারভাইজার কে। “আপনি কোন বিভাগের?” — এর উত্তর পেতে কর্মী তাঁর সুপারভাইজারকে জিজ্ঞেস করেন, তিনি তাঁর ওপরের জনকে, এভাবে যতক্ষণ না বিভাগীয় প্রধানের কাছে পৌঁছানো যায়, যাঁর ওপরে কেউ নেই। বিভাগীয় প্রধানই প্রতিনিধি। দুটি বিভাগ মিলিয়ে দিতে হলে কোনো কর্মীর কাগজ বদলাতে হয় না — শুধু এক প্রধানকে অন্য প্রধানের অধীনে দিলেই হয়।

Pseudocode — parent-pointer forest, no balancing yet (0-based)

makeSet(x):  p[x] = x                     // a root is its own parent
find(x):     while p[x] != x:  x = p[x]   // walk up to the root
             return x
union(a, b): ra = find(a);  rb = find(b)
             if ra != rb:  p[ra] = rb     // hang a's root under b's root

এখন union মাত্র একটি অ্যাসাইনমেন্ট (দুটি find বাদে)। কিন্তু find-এর খরচ নির্ভর করে tree কতটা উঁচু তার ওপর — আর union কোন root-কে কার নিচে বসাচ্ছে, তা নিয়ে আমরা কোনো নিয়ম রাখিনি। খারাপ ক্রমে কী হয়, দেখুন। পাঁচটি গ্রাম, আর প্রতিটি রাস্তা ০ নম্বর গ্রাম থেকে:

Forest (root ওপরে) আর parent অ্যারেসবুজ = এই ধাপে বদলানো

            
প্রতিটি ধাপের পর
এই ধাপের কাজ
find(0)-এর হাঁটা (hop)
মোট hop এ পর্যন্ত
tree-র উচ্চতা
  1. শুরু. make-set-এর পর প্রতিটি গ্রাম নিজেই নিজের root: p[x] = x. পাঁচটি দল, পাঁচটি এক-node-এর tree.
  2. union(0, 1). find(0) = 0, find(1) = 1 — দুজনই root, হাঁটতে হয়নি। নিয়ম অনুযায়ী a-র root 0 বসল b-র root 1-এর নিচে: p[0] = 1.
  3. union(0, 2). এবার find(0) এক ধাপ হাঁটে: 0 → 1. root 1 বসল 2-এর নিচে: p[1] = 2. Tree-র উচ্চতা ২.
  4. union(0, 3). find(0): 0 → 1 → 2, দুই ধাপ। p[2] = 3. শিকলটি লম্বা হচ্ছে, কারণ প্রতিবার বড় tree-টি গিয়ে বসছে এক-node-এর tree-র নিচে।
  5. union(0, 4). find(0) তিন ধাপ, তারপর p[3] = 4. এখন একটি লম্বা শিকল 0 → 1 → 2 → 3 → 4, উচ্চতা ৪ = n − 1. এটি আর tree-র মতো দেখায় না, একটি linked list.
  6. find(0). এখন একটি find-এ পুরো শিকল — ৪টি hop. union-গুলোতে hop ছিল 0 + 1 + 2 + 3; n টি গ্রামে এই ক্রম মোট প্রায় n²/2 hop নেয় — আবার Θ(n²). সমস্যা union-এ নয়, উচ্চতায়.

এই সরল forest-কে কখনো quick-union বলা হয়: union সস্তা, কিন্তু find-এর worst case O(n), কারণ tree-র উচ্চতা n − 1 পর্যন্ত যেতে পারে। পরের দুটি অংশে দুটি স্বাধীন কৌশল দেখব — একটি উচ্চতা বাড়তেই দেয় না, অন্যটি বেড়ে যাওয়া উচ্চতা কমিয়ে দেয়।

৪ · Union by size আর union by rank — ছোট tree বড়টির নিচে

আগের ডায়াগ্রামে দোষ ছিল একটাই: বড় tree গিয়ে বসছিল ছোটটির নিচে, ফলে বড় tree-র সব node-এর গভীরতা এক করে বাড়ছিল। উল্টোটা করুন। প্রতিটি root-এ তার tree-র আকার sz[root] রাখুন, আর union-এ ছোট tree-র root-কে বড় tree-র root-এর নিচে বসান — linked list-এর weighted union-এর মতোই। এর নাম union by size. সমান হলে যেকোনো একটি; এই পর্বে নিয়ম: প্রথম argument-এর root parent হবে।

Forest আর parent অ্যারেসবুজ = এই ধাপে যার parent বদলাল

            
প্রতিটি ধাপের পর
এই ধাপের union
node 3: গভীরতা / দলের আকার
node 7: গভীরতা / দলের আকার
সবচেয়ে উঁচু tree
  1. শুরু. আটটি গ্রাম, প্রত্যেকে নিজের root, প্রত্যেক দলের আকার ১।
  2. দুটি জোড়া. union(0,1): দুই দলের আকার ১ ও ১, সমান — প্রথমটির root 0 parent: p[1] = 0. একইভাবে p[3] = 2. node 3-এর গভীরতা ০ থেকে ১ হলো, আর তার দলের আকার ১ থেকে ২.
  3. union(0, 2). root 0 আর 2, দুজনের আকারই ২ — সমান, তাই 2 বসল 0-এর নিচে: p[2] = 0. node 3-এর গভীরতা আবার বাড়ল (২), আর তার দলের আকার আবার দ্বিগুণ (৪)। খেয়াল করুন, দুটি একসাথেই ঘটে।
  4. দ্বিতীয় চার-সদস্যের দল. একই ধাঁচে 4, 5, 6, 7 মিলে একটি tree: p[5] = 4, p[7] = 6, p[6] = 4. node 7-এর গভীরতা দুবার বেড়ে ২, আর প্রতিবার তার দল দ্বিগুণ: ১ → ২ → ৪.
  5. union(0, 4). দুই চার-সদস্যের দল জোড়া: p[4] = 0. node 7-এর গভীরতা ৩, দলের আকার ৮. উচ্চতা ৩ = log₂ 8 — এর চেয়ে বেশি কখনো হবে না। কিন্তু node 3-এর গভীরতা বাড়েনি: তার root 0 ছিল যে দলে, সেটি “জয়ী” পক্ষ।

প্রমাণ — উচ্চতা কখনো ⌊log₂ n⌋ ছাড়ায় না

দাবি: union by size ব্যবহার করলে যেকোনো node x-এর গভীরতা d হলে x-এর দলে অন্তত 2d টি সদস্য আছে। যেহেতু কোনো দলে n-এর বেশি সদস্য থাকতে পারে না, 2d ≤ n, অর্থাৎ d ≤ log₂ n — তাই প্রতিটি tree-র উচ্চতা ≤ ⌊log₂ n⌋.

যুক্তি: x-এর গভীরতা কখন বাড়ে? find বা union কোনো node-এর parent বদলায় না, শুধু union একটি root-এর parent বদলায়। তাই x-এর গভীরতা এক বাড়ে কেবল তখন, যখন x-এর tree-র root r অন্য একটি root s-এর নিচে বসে — আর নিয়ম অনুযায়ী তখন sz[r] ≤ sz[s]. জোড়ার পর x-এর দলের আকার sz[r] + sz[s] ≥ 2·sz[r]: অন্তত দ্বিগুণ। শুরুতে গভীরতা ০, দলের আকার ১ = 20. প্রতিবার গভীরতা এক বাড়লে আকার অন্তত দ্বিগুণ হয়, আর গভীরতা না বাড়লেও আকার কমে না। Induction-এ, গভীরতা d হলে আকার ≥ 2d. ∎ ওপরের ডায়াগ্রামে node 7-এর সারিটি ঠিক এই যুক্তিই দেখায়: 0/1 → 2/4 → 3/8.

ফল: find আর union দুটোই O(log n) worst case, প্রতিটি; বাড়তি জায়গা Θ(n) (দুটি অ্যারে p আর sz). সীমাটি নিখুঁত: ডায়াগ্রামের মতো “সমান আকারের দুটি দল বারবার জোড়া” দিলে n = 2k-এ উচ্চতা ঠিক k হয় — এই tree-গুলোকে binomial tree বলে।

Union by rank — আকারের বদলে উচ্চতার একটি সীমা

অনেক বই (CLRS সহ) আকারের বদলে প্রতিটি root-এ একটি rank রাখে: শুরুতে ০; দুই root জোড়ার সময় কম rank-এর root যায় বেশি rank-এর নিচে; rank সমান হলে যেকোনো একটি নিচে যায় আর ওপরেরটির rank এক বাড়ে। এটিই union by rank. Path compression না থাকলে rank ঠিক tree-র উচ্চতা; থাকলে rank শুধু উচ্চতার একটি ওপরের সীমা (compression উচ্চতা কমায়, কিন্তু rank-কে কেউ কমায় না — দরকারও নেই)।

প্রমাণ (rank সংস্করণ): rank r-এর প্রতিটি root-এর tree-তে অন্তত 2r টি node. r = 0-তে ১টি node, সত্য। rank r + 1 হয় কেবল তখন, যখন rank r-এর দুটি root জোড়া লাগে — দুই পাশে অন্তত 2r করে, মোট অন্তত 2r+1. অন্য union-এ বড় rank-এর root-এর rank বদলায় না, শুধু node বাড়ে। তাই rank ≤ log₂ n, আর উচ্চতা ≤ rank ≤ ⌊log₂ n⌋. ∎ বাড়তি সুবিধা: rank এত ছোট (৬৪-বিট মেশিনে ৬৪-এর কম) যে একটি byte-এই ধরে যায়।

মূল ধারণা

Size আর rank — দুটোই একই কাজ করে: “ভারী” পক্ষ ওপরে থাকে। Asymptotic ফল এক, path compression-এর সাথে মিলেও এক (O(α(n))). কনটেস্টে size বেশি জনপ্রিয়, কারণ সেটি একই সাথে “এই দলে কয়জন” প্রশ্নের উত্তরও দেয় (অংশ ৭)। আর মনে রাখুন, উচ্চতা ছোট রাখার প্রমাণ “গভীরতা বাড়লে দল দ্বিগুণ” — এই একটি বাক্যে লুকিয়ে আছে।

৫ · Path compression — একবার খুঁজলে পরের বার শর্টকাট

দ্বিতীয় কৌশলটি আসে একটি অলস কিন্তু বুদ্ধিমান পর্যবেক্ষণ থেকে। find(x) যখন x থেকে root পর্যন্ত হাঁটে, পথের প্রতিটি node-এর root কে, তা আমরা জেনেই ফেলি। তাহলে ফেরার পথে প্রত্যেককে সরাসরি root-এর সাথে জুড়ে দিই না কেন? পরের বার তাদের কেউ find করলে এক লাফেই root. এটিই path compression. উপমায়: একজন নতুন কর্মী একবার কষ্ট করে জেনে নিলেন বিভাগীয় প্রধান কে; এরপর তিনি আর মাঝের সুপারভাইজারদের জিজ্ঞেস করেন না, সরাসরি প্রধানের কাছে যান — আর পথে যাঁদের জিজ্ঞেস করেছিলেন, তাঁরাও এখন প্রধানকে সরাসরি চেনেন।

Pseudocode — find with path compression (0-based)

find(x):                                  // recursive version
    if p[x] != x:  p[x] = find(p[x])      // point straight at the root
    return p[x]

find(x):                                  // iterative, two passes
    r = x
    while p[r] != r:  r = p[r]            // pass 1: locate the root
    while p[x] != r:  next = p[x];  p[x] = r;  x = next   // pass 2: re-link

আগের ডায়াগ্রামের শেষ tree নিন (উচ্চতা ৩), আর পরপর find(7), find(5), find(3) চালান:

Forest আর parent অ্যারেসবুজ = এই ধাপে পথ বা নতুন parent

            
প্রতিটি ধাপের পর
এই ধাপের কাজ
পথ / hop
বদলানো parent
tree-র উচ্চতা
  1. শুরু. union by size-এর পর একটি tree, root 0, উচ্চতা ৩. সবচেয়ে গভীর node 7.
  2. প্রথম পাস. find(7) parent ধরে হাঁটে: 7 → 6 → 4 → 0. তিনটি hop, root 0 পাওয়া গেল। এ পর্যন্ত সাধারণ find-এর মতোই।
  3. দ্বিতীয় পাস. ফেরার পথে পথের প্রতিটি node-এর parent সরাসরি 0: p[7] = 0, p[6] = 0 (p[4] আগেই 0). খরচ একই O(পথের দৈর্ঘ্য), কিন্তু এখন 7 আর 6 এক লাফে root-এ। খেয়াল করুন, 6-এর পুরোনো সন্তান 7 তার সাথে যায়নি — 7 নিজেই root-এ জুড়েছে।
  4. find(5). 5 → 4 → 0, দুই hop; তারপর p[5] = 0. Tree আরও চ্যাপ্টা — 0-এর এখন ছয়টি সরাসরি সন্তান।
  5. find(3). 3 → 2 → 0; p[3] = 0. এখন সবাই root-এর সরাসরি সন্তান — একটি “তারা” (star), উচ্চতা ১. এরপর যেকোনো find এক hop. যে find-গুলো ব্যয়বহুল ছিল, সেগুলোই পরের find-গুলোকে সস্তা করে দিয়েছে — এটিই amortized বিশ্লেষণের মূল কথা।

দুটি খুঁটিনাটি লক্ষ করুন। প্রথমত, path compression শুধু find-এর পথের node-গুলোর parent বদলায়; কারও দল বদলায় না, তাই DSU-এর উত্তর একই থাকে। দ্বিতীয়ত, compression rank বা size-কে নষ্ট করে না: root-এর sz সঠিকই থাকে (দলের সদস্য-সংখ্যা বদলায়নি), শুধু rank আর “ঠিক উচ্চতা” থাকে না, থাকে উচ্চতার একটি ওপরের সীমা।

Path halving আর path splitting — এক পাসের বিকল্প

Recursive find সুন্দর, কিন্তু খুব লম্বা পথে recursion-এর গভীরতা বাড়ে, আর দুই-পাসের লুপ লিখতে একটু বেশি কোড। Tarjan আর van Leeuwen (১৯৮৪) দেখান যে দুটি এক-পাসের সরল রূপও একই O(α(n)) সীমা দেয়:

  • Path halving: হাঁটার সময় প্রতি দ্বিতীয় node-কে তার দাদার (grandparent) সাথে জুড়ুন, আর সরাসরি দাদার কাছে লাফ দিন: p[x] = p[p[x]]; x = p[x];
  • Path splitting: পথের প্রত্যেক node-কে তার দাদার সাথে জুড়ুন, কিন্তু হাঁটুন এক ধাপ করে।
Forest আর parent অ্যারেসবুজ = বর্তমান x বা নতুন parent

            
প্রতিটি ধাপের পর
পদ্ধতি
এই ধাপের কাজ
5-এর গভীরতা
tree-র উচ্চতা
  1. শুরু. একটি লম্বা শিকল (নিয়ম ছাড়া union-এর ফল): p = [0, 0, 1, 2, 3, 4], root 0. find(5) চালাব path halving দিয়ে।
  2. x = 5. 5-এর parent 4, দাদা 3. p[5] = 3, তারপর x সরাসরি 3-এ লাফ দিল। 4 পেছনে রয়ে গেল — তার parent বদলায়নি।
  3. x = 3. 3-এর দাদা 1: p[3] = 1, x = 1. এখন 1-এর দুই সন্তান, 2 আর 3.
  4. x = 1. 1-এর parent 0, আর 0-এর parent 0 নিজেই — তাই p[1] = 0 কিছু বদলায় না, x = 0 root. এক পাসে শেষ। 5-এর গভীরতা ৫ থেকে ৩ — প্রায় অর্ধেক, নামের সাথে মিল রেখে। কোনো বাড়তি লুপ বা recursion লাগেনি।
  5. তুলনা — full compression. একই শিকলে দুই-পাসের compression হলে পথের সবাই সরাসরি 0-এর নিচে: p = [0, 0, 0, 0, 0, 0]. এক find-এ বেশি চ্যাপ্টা, কিন্তু দুই পাস লাগে।
  6. তুলনা — splitting. Path splitting-এ পথের প্রত্যেকে দাদার নিচে: p = [0, 0, 0, 1, 2, 3]. শিকলটি দুটি অর্ধেক-দৈর্ঘ্যের শাখায় “ভাগ” হলো — নামটি সেখান থেকে। তিন পদ্ধতিই amortized O(α(n)) দেয়; বাস্তবে halving প্রায়ই সবচেয়ে দ্রুত, কারণ এক পাস আর কম লেখা।

পুরো কোড — গ্রামের রাস্তা আর প্রশ্ন

এবার অংশ ১-এর গল্পটি কোডে। আটটি গ্রাম; একটি তালিকায় রাস্তা খোলার খবর ("a b"), প্রশ্ন ("? a b") আর মাঝে মাঝে parent অ্যারে দেখার নির্দেশ ("show"). DSU-তে union by size আর path compression দুটোই আছে, আর একটি counter রাখে এখন কয়টি দল (component). C++-এ union একটি সংরক্ষিত শব্দ, তাই ফাংশনের নাম unite.

#include <iostream>
#include <numeric>
#include <string>
#include <utility>
#include <vector>
using namespace std;
struct DSU {
    vector<int> p, sz; int comps;
    DSU(int n) : p(n), sz(n, 1), comps(n) { iota(p.begin(), p.end(), 0); }   // make-set
    int find(int x) { return p[x] == x ? x : p[x] = find(p[x]); }           // path compression
    bool unite(int a, int b) {                                              // union by size
        a = find(a); b = find(b);
        if (a == b) return false;           // already connected: this road closes a cycle
        if (sz[a] < sz[b]) swap(a, b);
        p[b] = a; sz[a] += sz[b]; --comps; return true;
    }
};
int main() {
    DSU d(8);
    vector<string> events = {"0 1", "2 3", "1 3", "show", "4 5", "6 7", "5 7", "0 2", "show",
                             "? 0 3", "? 2 6", "3 6", "? 2 6", "show"};
    for (const string& e : events) {
        if (e == "show") {
            cout << "parent:"; for (int x : d.p) cout << ' ' << x;
            cout << " | components = " << d.comps << '\n';
        } else if (e[0] == '?') {
            int a = e[2] - '0', b = e[4] - '0';
            cout << "query " << a << '-' << b << ": " << (d.find(a) == d.find(b) ? "yes" : "no") << '\n';
        } else {
            int a = e[0] - '0', b = e[2] - '0';
            cout << "road " << a << '-' << b << ": " << (d.unite(a, b) ? "joined" : "already connected (cycle)") << '\n';
        }
    }
}

আউটপুট:

road 0-1: joined
road 2-3: joined
road 1-3: joined
parent: 0 0 0 2 4 5 6 7 | components = 5
road 4-5: joined
road 6-7: joined
road 5-7: joined
road 0-2: already connected (cycle)
parent: 0 0 0 2 4 4 4 6 | components = 2
query 0-3: yes
query 2-6: no
road 3-6: joined
query 2-6: yes
parent: 0 0 0 0 0 4 0 6 | components = 1

প্রথম show-এ parent: 0 0 0 2 …: রাস্তা 1-3 যখন এল, find(1) = 0 আর find(3) = 2, দুই দলের আকার ২ — সমান, তাই 2 গেল 0-এর নিচে, আর 3 রয়ে গেল 2-এর নিচে (গভীরতা ২)। রাস্তা 0-2 এল যখন 0 আর 2 আগেই একই দলে — unite false ফেরত দিল, অর্থাৎ এই রাস্তা একটি cycle বানাত (অংশ ৭-এ এর ব্যবহার)। দ্বিতীয় আর তৃতীয় show-এর মাঝে লক্ষ করুন p[3] 2 থেকে 0 হয়ে গেছে: কেউ 3-কে সরাসরি union করেনি — প্রশ্ন ? 0 3-এর find(3)-ই path compression করেছে। একইভাবে দ্বিতীয় ? 2 6-এর find(6) p[6]-কে 0 করেছে। প্রতিটি সফল union-এ component এক কমে: ৮ − ৭ = ১. Recursive find এখানে নিরাপদ, কারণ union by size-এ গভীরতা log₂ n-এর বেশি নয়।

class DSU:
    def __init__(self, n):
        self.p = list(range(n))           # make-set for every village
        self.sz = [1] * n
        self.comps = n
    def find(self, x):                    # path compression, two passes
        r = x
        while self.p[r] != r:
            r = self.p[r]
        while self.p[x] != r:
            self.p[x], x = r, self.p[x]
        return r
    def union(self, a, b):                # union by size
        a, b = self.find(a), self.find(b)
        if a == b:
            return False                  # already connected: this road closes a cycle
        if self.sz[a] < self.sz[b]:
            a, b = b, a
        self.p[b] = a
        self.sz[a] += self.sz[b]
        self.comps -= 1
        return True

d = DSU(8)
events = ["0 1", "2 3", "1 3", "show", "4 5", "6 7", "5 7", "0 2", "show",
          "? 0 3", "? 2 6", "3 6", "? 2 6", "show"]
for e in events:
    if e == "show":
        print("parent:", *d.p, "| components =", d.comps)
    elif e[0] == "?":
        _, a, b = e.split()
        print(f"query {a}-{b}:", "yes" if d.find(int(a)) == d.find(int(b)) else "no")
    else:
        a, b = map(int, e.split())
        print(f"road {a}-{b}:", "joined" if d.union(a, b) else "already connected (cycle)")

আউটপুট C++-এর সাথে হুবহু এক:

road 0-1: joined
road 2-3: joined
road 1-3: joined
parent: 0 0 0 2 4 5 6 7 | components = 5
road 4-5: joined
road 6-7: joined
road 5-7: joined
road 0-2: already connected (cycle)
parent: 0 0 0 2 4 4 4 6 | components = 2
query 0-3: yes
query 2-6: no
road 3-6: joined
query 2-6: yes
parent: 0 0 0 0 0 4 0 6 | components = 1

Python-এ find লুপ দিয়ে লেখা, কারণ Python-এর recursion-এর সীমা (সাধারণত ১০০০) ছোট, আর লম্বা শিকলে recursive find RecursionError দিতে পারে। দ্বিতীয় লুপের self.p[x], x = r, self.p[x] লাইনে ডান পাশ আগে পুরোটা হিসাব হয় (r আর পুরোনো parent), তারপর বাঁ থেকে অ্যাসাইন — তাই আগে p[x] = r, তারপর x যায় পুরোনো parent-এ। দুটি find-এর ফল একই, তাই parent অ্যারেও C++-এর সাথে প্রতিটি checkpoint-এ মেলে। n টি make-set O(n), প্রতিটি পরের কাজ amortized O(α(n)), জায়গা Θ(n).

৬ · O(α(n)) — প্রায় ধ্রুবক, কিন্তু ঠিক ধ্রুবক নয়

শুধু union by size: প্রতিটি কাজ O(log n). শুধু path compression (কোনো balancing ছাড়া): m টি কাজে মোট O(m log n)-এর মতো — একটি একক find O(n) হতে পারে, কিন্তু গড়ে log. দুটো একসাথে দিলে? ১৯৭৩ সালে John Hopcroft আর Jeffrey Ullman প্রমাণ করেন O(log* n) প্রতি কাজে, যেখানে log* n (iterated logarithm) মানে n-কে কতবার log₂ করলে ১-এর নিচে বা সমান নামে। log* 265536 = 5 — এটিই ইতিমধ্যে অবিশ্বাস্য ধীরে বাড়া একটি ফাংশন।

১৯৭৫ সালে Robert Tarjan আরও নিখুঁত উত্তর দেন: m টি কাজে (যার মধ্যে n টি make-set) মোট সময় O(m α(n)), আর এই বিশ্লেষণ ঠিক — শক্ত (tight). এখানে α হলো inverse Ackermann ফাংশন। Ackermann ফাংশন A (Wilhelm Ackermann, ১৯২৮) এত দ্রুত বাড়ে যে exponential, tower — কোনো পরিচিত ফাংশন তার সাথে পাল্লা দিতে পারে না। α(n) হলো তার উল্টো: “কোন ছোট k-তে A-এর মান n ছাড়ায়?”

n যদি মহাবিশ্বের পরমাণুর সংখ্যাও (প্রায় 1080) হয়, তবু α(n) ≤ 4

কতটা ধীরে বাড়ে, তা অনুভব করতে CLRS-এর সংজ্ঞাটি দেখুন। A0(j) = j + 1, আর Ak(j) মানে Ak−1-কে j + 1 বার পরপর প্রয়োগ। তাহলে A1(1) = 3, A2(1) = 7, A3(1) = 2047, আর A4(1) এমন একটি সংখ্যা যা 22048-এরও অনেক অনেক গুণ বড় — 1080-কে বামন করে দেয়। α(n) = সবচেয়ে ছোট k, যাতে Ak(1) ≥ n. তাই n ≤ 2047 হলে α(n) ≤ 3, আর বাস্তবে কখনো দেখা যাবে এমন যেকোনো n-এ α(n) ≤ 4. (বিভিন্ন বইয়ে সংজ্ঞা সামান্য আলাদা, কিন্তু সবখানেই α “বাস্তবে ছোট ধ্রুবক”।)

তাহলে কেন O(1) বলা হয় না? কারণ গাণিতিকভাবে α(n) অসীমের দিকে যায় — খুব খুব ধীরে, কিন্তু যায়। আর এটি শুধু বিশ্লেষণের দুর্বলতা নয়: Tarjan ১৯৭৯ সালে একটি নির্দিষ্ট মডেলে (pointer machine) প্রমাণ করেন যে এর চেয়ে ভালো সম্ভব নয়, আর Michael Fredman ও Michael Saks ১৯৮৯ সালে cell probe মডেলে দেখান যে যেকোনো union-find ডেটা স্ট্রাকচারকে amortized Ω(α(n)) দিতেই হবে। অর্থাৎ এই সরল দশ লাইনের কোডটি তাত্ত্বিকভাবে সর্বোত্তম।

“Amortized” শব্দটি বাদ দেবেন না। O(α(n)) হলো পুরো ক্রমের গড় সীমা: m টি কাজ মিলে O(m α(n)). একটি নির্দিষ্ট find তখনও O(log n) সময় নিতে পারে (union by size থাকায় এর বেশি নয়) — যেমন ওপরের ডায়াগ্রামে প্রথম find(7) নিয়েছে ৩ hop. Real-time সিস্টেমে, যেখানে প্রতিটি কাজের সময়সীমা আছে, এই পার্থক্য গুরুত্বপূর্ণ। বিস্তারিত amortized analysis পর্ব ৬০-এ। প্রমাণটি এই পর্বে দিচ্ছি না — CLRS-এর অধ্যায় ১৯-এ (পুরোনো সংস্করণে ২১) potential function দিয়ে প্রায় দশ পৃষ্ঠার প্রমাণ আছে।

কৌশলএকটি কাজ, worst casem টি কাজ মিলে
কিছুই না (সরল forest)O(n)Θ(mn) পর্যন্ত
শুধু union by size/rankO(log n)O(m log n)
শুধু path compressionO(n)মোটামুটি O(m log n); m অনেক বড় হলে আরও ভালো
দুটোই (বা rank + halving/splitting)O(log n)O(m α(n))

৭ · প্রয়োগ — Kruskal থেকে ছবির অংশ চেনা পর্যন্ত

Kruskal-এর minimum spanning tree

DSU-এর সবচেয়ে বিখ্যাত ব্যবহার। ধরুন আটটি গ্রামকে বিদ্যুতের লাইনে জুড়তে হবে, প্রতিটি সম্ভাব্য লাইনের খরচ জানা, আর সবচেয়ে কম খরচে সব গ্রাম যুক্ত করতে হবে — এটিই minimum spanning tree (MST). Kruskal-এর অ্যালগরিদম (Joseph Kruskal, ১৯৫৬): সব edge খরচ অনুযায়ী সাজান, তারপর সস্তা থেকে দামি ক্রমে প্রতিটি edge নিন — যদি তার দুই প্রান্ত এখনো আলাদা component-এ থাকে। একই component-এ থাকলে সেই edge একটি cycle বানাত, তাই বাদ। “এখনো আলাদা কি না” — ঠিক DSU-এর প্রশ্ন।

// Kruskal: edges as (weight, u, v); DSU from the code above
sort(edges.begin(), edges.end());              // O(E log E)
DSU d(n); long long total = 0; int used = 0;
for (auto [w, u, v] : edges)
    if (d.unite(u, v)) { total += w; ++used; } // keep the edge only if it joins two components
// used == n - 1 means the graph was connected; total = MST weight

সময় O(E log E) সর্টের জন্য, আর DSU-এর অংশ মাত্র O(E α(V)) — সর্টই বাধা। কেন এই greedy ঠিক (cut property), তা প্রমাণসহ পর্ব ৪৫-এ।

Connected component গোনা আর undirected গ্রাফে cycle ধরা

দুটি কাজই ওপরের কোডে আছে। Component-এর সংখ্যা = n − (সফল union-এর সংখ্যা), কারণ প্রতিটি সফল union দুটি দলকে এক করে। আর একটি undirected graph-এর edge একে একে নেওয়ার সময় যদি কোনো edge (u, v)-তে find(u) == find(v) হয়, তাহলে u আর v-এর মধ্যে আগেই একটি পথ ছিল — নতুন edge সেই পথের সাথে মিলে একটি cycle বানায়। এই cycle detection O(E α(V))-এ চলে, আর একই সাথে বলে দেয় কোন edge-টি প্রথম cycle বানাল। সতর্কতা: এটি শুধু undirected গ্রাফে কাজ করে; directed গ্রাফে cycle খুঁজতে DFS-এর রং-পদ্ধতি লাগে (পর্ব ৪১–৪২), কারণ সেখানে “একই দলে” মানে cycle নয়।

Offline প্রশ্ন — সময়কে উল্টো দিকে চালানো

DSU দল ভাঙতে পারে না। কিন্তু সব প্রশ্ন যদি আগে থেকে জানা থাকে (offline), তাহলে একটি কৌশল আছে। ধরুন রাস্তাগুলো একে একে ভাঙছে, আর প্রতিটি ভাঙার পর জানতে চাই কয়টি component. ক্রমটি উল্টে দিন: শুরু করুন শেষ অবস্থা থেকে (যেসব রাস্তা কখনো ভাঙেনি, শুধু সেগুলো জুড়ে), তারপর ভাঙার উল্টো ক্রমে রাস্তাগুলো যোগ করুন। “ভাঙা” হয়ে গেল “জোড়া” — DSU-এর নিজের কাজ। উত্তরগুলো উল্টো ক্রমে জমা করে শেষে আবার উল্টে দিলেই হলো, মোট O((n + m) α(n)). একইভাবে, “শুধু w-এর চেয়ে কম ওজনের edge ব্যবহার করে u থেকে v যাওয়া যায়?” — প্রশ্নগুলো w অনুযায়ী আর edge-গুলো ওজন অনুযায়ী সাজিয়ে, দুই তালিকা একসাথে এগিয়ে, প্রতিটি প্রশ্নের আগে সব হালকা edge জুড়ে দিন। Tree-তে offline LCA-র Tarjan-এর পদ্ধতিও DSU দিয়ে চলে (পর্ব ৪৮)।

Percolation আর ছবির অংশ চিহ্নিত করা

একটি n × n গ্রিডের প্রতিটি ঘর p সম্ভাবনায় খোলা। ওপরের সারি থেকে খোলা ঘর ধরে নিচের সারিতে পৌঁছানো যায় কি? — এটি পদার্থবিজ্ঞানের percolation সমস্যা: পানি কি মাটির স্তর ভেদ করে চুইয়ে নামবে? DSU-তে প্রতিটি খোলা ঘরকে তার খোলা প্রতিবেশীর সাথে union করুন, আর দুটি কাল্পনিক node রাখুন — “ওপর” (ওপরের সারির সব খোলা ঘর এর সাথে যুক্ত) আর “নিচ”. find(ওপর) == find(নিচ) হলেই চুইয়ে নামে। এলোমেলো ঘর একে একে খুলে কখন প্রথম চুইয়ে নামে তা দেখার Monte Carlo পরীক্ষায় বর্গ গ্রিডে সেই সীমা প্রায় p ≈ 0.5927.

ছবিতেও একই কাজ: একটি সাদা-কালো ছবিতে পাশাপাশি থাকা কালো pixel-গুলো মিলে কয়টি আলাদা বস্তু? — connected component labeling. প্রচলিত দুই-পাস পদ্ধতিতে প্রথম পাসে প্রতিটি pixel-কে বাঁ আর ওপরের প্রতিবেশীর লেবেল দেওয়া হয়; দুই লেবেল মুখোমুখি হলে তাদের union. দ্বিতীয় পাসে প্রতিটি লেবেলকে তার find দিয়ে বদলে দেওয়া হয়। ১০ লাখ pixel-এর ছবিতে এটি প্রায় রৈখিক সময়ে চলে — OCR-এ অক্ষর আলাদা করা, মেডিক্যাল ছবিতে কোষ গোনা বা স্যাটেলাইট ছবিতে বন্যার পানির এলাকা মাপায় এই কৌশল ব্যবহার হয়।

প্রতিটি দলের সাথে বাড়তি তথ্য

Root-ই দলের প্রতিনিধি, তাই দলের যেকোনো সমষ্টিগত তথ্য — সদস্য-সংখ্যা, মোট টাকা, সবচেয়ে ছোট মান — শুধু root-এ রাখলেই চলে, আর union-এ দুই root-এর তথ্য জুড়ে দিলেই হলো। ধরুন প্রতিটি গ্রামে কিছু ত্রাণের প্যাকেট আছে: sum[root]-এ দলের মোট প্যাকেট রাখুন; union-এ sum[a] += sum[b]; প্রশ্ন “x-এর দলে মোট কত প্যাকেট?” = sum[find(x)]. sz অ্যারেটি আসলে এরই একটি উদাহরণ। শর্ত একটাই: তথ্যটিকে “জোড়া” যেতে হবে (যোগ, min, max, gcd — হ্যাঁ; median — সহজে নয়)। আর মনে রাখুন, non-root node-এর এই ঘরগুলো পুরোনো, অর্থহীন মান রাখতে পারে — সবসময় find(x) দিয়ে পড়ুন।

৮ · Parity DSU — “শত্রুর শত্রু বন্ধু”

একটি কলেজের বিতর্ক উৎসবে পাঁচজন অংশগ্রহণকারীকে দুটি দলে ভাগ করতে হবে। আয়োজকের কাছে কিছু তথ্য আসছে: “0 আর 1 প্রতিপক্ষ (আলাদা দলে)”, “3 আর 4 একই দলে” — এমন। প্রতিটি নতুন তথ্যে প্রশ্ন: এ পর্যন্ত সব তথ্য কি একসাথে সত্য হতে পারে, নাকি কোথাও স্ববিরোধ (contradiction)? এখানে দলের ভেতরে আরেকটি তথ্য লাগে: শুধু “একই component-এ” নয়, “একই পক্ষে না উল্টো পক্ষে”.

Parity DSU-তে প্রতিটি node x একটি বাড়তি bit রাখে, par[x]: x তার parent-এর সাথে একই দলে (0) না উল্টো দলে (1). Root পর্যন্ত পথের সব bit-এর XOR = x আর root-এর সম্পর্ক। কেন XOR? “শত্রুর শত্রু বন্ধু”: উল্টো ⊕ উল্টো = একই, ঠিক 1 ⊕ 1 = 0. find(x) এখন দুটি জিনিস ফেরত দেয় — root, আর x-এর parity root-এর সাপেক্ষে; path compression-এর সময় parity-ও জমা হয়ে নতুন parent (root)-এর সাপেক্ষে হয়ে যায়।

  • নতুন তথ্য (a, b, d), যেখানে d = 1 মানে প্রতিপক্ষ, 0 মানে একই দল। find(a) = (ra, pa), find(b) = (rb, pb).
  • ra ≠ rb: দুই দল এখনো সম্পর্কহীন — জুড়ে দিন। rb-কে ra-এর নিচে বসালে চাই pa ⊕ par[rb] ⊕ pb = d, তাই par[rb] = pa ^ pb ^ d.
  • ra == rb: সম্পর্ক আগেই নির্ধারিত, pa ⊕ pb. সেটি d-এর সমান হলে সামঞ্জস্যপূর্ণ, না হলে স্ববিরোধ.
প্রতিটি তথ্যের পর (p = parent, par = parent-এর সাপেক্ষে পক্ষ)
নতুন তথ্য
find-এর ফল (root, parity)
সিদ্ধান্ত
p
par
দল (root-এর পক্ষ / উল্টো পক্ষ)
  1. 0, 1 প্রতিপক্ষ. দুজনই একা, আলাদা root. 1 গেল 0-এর নিচে, আর par[1] = 1: “1 তার parent 0-এর উল্টো পক্ষে”.
  2. 1, 2 প্রতিপক্ষ. find(1) = (0, 1): 1 root-এর উল্টো পক্ষে। 2 একা, (2, 0). 2-কে 0-এর নিচে বসাতে চাই 1 ⊕ par[2] ⊕ 0 = 1, তাই par[2] = 0 — 2 আর 0 একই পক্ষে। শত্রুর শত্রু বন্ধু!
  3. 0, 2 একই দল. দুজনেরই root 0, দুজনেরই parity 0. সম্পর্ক 0 ⊕ 0 = 0 = দাবি। নতুন কিছু শেখা হলো না, কিন্তু স্ববিরোধও নেই।
  4. 2, 3 প্রতিপক্ষ. find(2) = (0, 0), 3 একা। par[3] = 0 ⊕ 0 ⊕ 1 = 1: 3 আছে 0-এর উল্টো পক্ষে, অর্থাৎ 1-এর পক্ষে।
  5. 3, 4 একই দল. find(3) = (0, 1). par[4] = 1 ⊕ 0 ⊕ 0 = 1: 4-ও 0-এর উল্টো পক্ষে। এখন দুই দল {0, 2} আর {1, 3, 4} — সব তথ্য এখনো সামঞ্জস্যপূর্ণ।
  6. 4, 1 প্রতিপক্ষ. find(4) = (0, 1), find(1) = (0, 1). দুজনেই root-এর উল্টো পক্ষে — তাই তারা একে অন্যের একই পক্ষে, 1 ⊕ 1 = 0. কিন্তু দাবি বলছে প্রতিপক্ষ (1). স্ববিরোধ! কারণের শিকলটি: 4 = 3 ≠ 2 = 0 ≠ 1, তাই 4 = 1.
#include <cstdio>
#include <tuple>
#include <utility>
#include <vector>
using namespace std;
vector<int> p, par, sz;            // par[x] = 1: x is on the other team from p[x]
pair<int, int> findp(int x) {      // (root, parity of x relative to the root)
    if (p[x] == x) return {x, 0};
    auto [r, q] = findp(p[x]);
    par[x] ^= q; p[x] = r;         // path compression keeps the parity to the root
    return {r, par[x]};
}
int main() {
    int n = 5;
    p.resize(n); par.assign(n, 0); sz.assign(n, 1);
    for (int i = 0; i < n; ++i) p[i] = i;
    vector<tuple<int, int, int>> claims = {{0, 1, 1}, {1, 2, 1}, {0, 2, 0}, {2, 3, 1}, {3, 4, 0}, {4, 1, 1}};
    for (auto [a, b, d] : claims) {            // d = 1: enemies, d = 0: same team
        auto [ra, pa] = findp(a);
        auto [rb, pb] = findp(b);
        const char* word = d ? "enemies" : "friends";
        if (ra == rb) {
            bool ok = (pa ^ pb) == d;
            printf("%d %d %s: already implied, %s\n", a, b, word, ok ? "consistent" : "CONTRADICTION");
            if (!ok) break;
        } else {
            if (sz[ra] < sz[rb]) swap(ra, rb);   // union by size; the parity formula is symmetric
            p[rb] = ra; par[rb] = pa ^ pb ^ d; sz[ra] += sz[rb];
            printf("%d %d %s: recorded\n", a, b, word);
        }
    }
    printf("parent:"); for (int x : p) printf(" %d", x);
    printf(" | parity:"); for (int x : par) printf(" %d", x);
    printf("\n");
}

আউটপুট:

0 1 enemies: recorded
1 2 enemies: recorded
0 2 friends: already implied, consistent
2 3 enemies: recorded
3 4 friends: recorded
4 1 enemies: already implied, CONTRADICTION
parent: 0 0 0 0 0 | parity: 0 1 0 1 1

প্রতিটি লাইন ওপরের ডায়াগ্রামের এক-একটি ধাপ, আর শেষ অবস্থার parity: 0 1 0 1 1 বলে দেয় দুই দল: parity 0 → {0, 2}, parity 1 → {1, 3, 4}. Recursive findp-এ আগে parent-এর (root, q) বের হয়, তারপর par[x] ^= q — x-এর পুরোনো parent-এর সাপেক্ষে parity-র সাথে সেই parent-এর root-এর সাপেক্ষে parity জুড়ে x সরাসরি root-এর সাপেক্ষে হয়ে যায়। ফাংশনের নাম findp, যাতে using namespace std-এর std::find-এর সাথে গোলমাল না হয়। Union by size-এ ra আর rb অদলবদল করলেও সূত্র একই থাকে, কারণ XOR প্রতিসম। প্রতিটি তথ্য amortized O(α(n)).

n = 5
p, par, sz = list(range(n)), [0] * n, [1] * n   # par[x] = 1: x is on the other team from p[x]

def find(x):                       # returns (root, parity of x relative to the root)
    if p[x] == x:
        return x, 0
    r, q = find(p[x])
    par[x] ^= q                    # path compression keeps the parity to the root
    p[x] = r
    return r, par[x]

claims = [(0, 1, 1), (1, 2, 1), (0, 2, 0), (2, 3, 1), (3, 4, 0), (4, 1, 1)]
for a, b, d in claims:             # d = 1: enemies, d = 0: same team
    (ra, pa), (rb, pb) = find(a), find(b)
    word = "enemies" if d else "friends"
    if ra == rb:
        ok = (pa ^ pb) == d
        print(f"{a} {b} {word}: already implied, {'consistent' if ok else 'CONTRADICTION'}")
        if not ok:
            break
    else:
        if sz[ra] < sz[rb]:        # union by size; the parity formula is symmetric
            ra, rb = rb, ra
        p[rb], par[rb] = ra, pa ^ pb ^ d
        sz[ra] += sz[rb]
        print(f"{a} {b} {word}: recorded")
print("parent:", *p, "| parity:", *par)

আউটপুট C++-এর সাথে হুবহু এক:

0 1 enemies: recorded
1 2 enemies: recorded
0 2 friends: already implied, consistent
2 3 enemies: recorded
3 4 friends: recorded
4 1 enemies: already implied, CONTRADICTION
parent: 0 0 0 0 0 | parity: 0 1 0 1 1

এখানে find recursive, কারণ উদাহরণ ছোট আর union by size থাকায় গভীরতা log₂ n-এর বেশি হয় না — দশ লাখ উপাদানেও প্রায় ২০, Python-এর recursion সীমার অনেক নিচে। p[rb], par[rb] = ra, pa ^ pb ^ d এক লাইনে দুটি অ্যাসাইনমেন্ট। স্ববিরোধ পেলেই লুপ থামে (break), তাই শেষ লাইনে অবস্থা ষষ্ঠ তথ্যের আগের মতোই থাকে।

এটি আসলে bipartite graph যাচাই: প্রতিটি “প্রতিপক্ষ” তথ্য একটি edge, আর প্রশ্ন হলো vertex-গুলোকে দুই রঙে রাঙানো যায় কি না, যাতে প্রতিটি edge-এর দুই প্রান্ত আলাদা রঙের। BFS দিয়েও করা যায় (পর্ব ৪০), কিন্তু edge যখন একে একে আসে আর প্রতিটির পর উত্তর চাই, তখন parity DSU সবচেয়ে সহজ। একই ধারণা আরও সাধারণ করা যায় — weighted DSU: par[x]-এর জায়গায় diff[x] = (x-এর মান) − (parent-এর মান) রাখুন, XOR-এর জায়গায় যোগ। তখন “A-র ওজন B-র চেয়ে ৩ কেজি বেশি” ধরনের তথ্য জমা করা আর স্ববিরোধ ধরা যায়। Mod 3 দিয়ে করলে “A খায় B-কে” ধাঁচের তিন-প্রজাতির খাদ্যশৃঙ্খলের একটি পরিচিত কনটেস্ট-সমস্যাও একই ছাঁচে পড়ে।

DSU যা পারে না

DSU-এর গতির দাম হলো সীমিত ক্ষমতা। (১) দল ভাঙা (split) বা edge মুছে ফেলা দক্ষভাবে সম্ভব নয় — parent pointer থেকে বোঝা যায় না কোন union কোন edge-এর জন্য হয়েছিল, আর path compression সেই ইতিহাসও মুছে দেয়। (২) একটি উপাদান মুছে ফেলা: প্রচলিত কৌশল হলো তাকে “মৃত” চিহ্ন দিয়ে আলাদা নতুন id-তে একা একটি দল খোলা, কিন্তু সেটি পুরোনো tree থেকে তাকে সত্যিই বের করে না। (৩) দলের সদস্যদের তালিকা: parent pointer শুধু ওপরে যায়, তাই সদস্য দেখাতে বাড়তি একটি circular linked list (প্রতি দলে “next” pointer, union-এ দুই চক্র জুড়ে দেওয়া, O(1)) রাখতে হয়। মুছে ফেলা আর জোড়া দুটোই লাগলে fully dynamic connectivity-এর জটিল স্ট্রাকচার লাগে, যেগুলো polylog সময় নেয়।

আরও গভীরে — rollback-যোগ্য DSU: কখনো শেষ কয়েকটি union “undo” করতে হয় — যেমন সময়ের ওপর segment tree বা divide and conquer দিয়ে offline dynamic connectivity, বা backtracking-এ। তখন path compression বাদ দিন (সে অনেক parent একসাথে বদলায়, ফেরানো কঠিন) আর শুধু union by size রাখুন; প্রতিটি union একটি stack-এ (b, a-র পুরোনো size) রাখে, আর undo মানে p[b] = b; sz[a] -= sz[b]. প্রতিটি find তখন O(log n) worst case, undo O(1) — amortized সীমার চেয়ে খারাপ, কিন্তু worst-case নিশ্চয়তা পাওয়া যায়, যা rollback-এর জন্য জরুরি। DSU with rollback-এর এই ধারণা কনটেস্টে “edge যোগ-মোছার পর প্রতিটি মুহূর্তে component কয়টি” ধরনের সমস্যার মানক সমাধান।

পরীক্ষা ও ইন্টারভিউতে:

  • শুধু union by rank দিলে n উপাদানের DSU-তে tree-র উচ্চতা সর্বোচ্চ কত? — ⌊log₂ n⌋; কারণ rank r-এর root-এর tree-তে অন্তত 2r টি node.
  • Union by rank আর path compression একসাথে m টি কাজের সময়? — O(m α(n)) amortized (Tarjan, ১৯৭৫); একটি একক কাজ তবু O(log n) পর্যন্ত হতে পারে।
  • DSU দিয়ে undirected গ্রাফে cycle কীভাবে ধরবেন? — প্রতিটি edge (u, v)-তে find(u) == find(v) হলে cycle, নইলে union; O(E α(V)).
  • Kruskal-এ DSU কেন? — কোনো edge দুটি আলাদা component জোড়ে কি না, তা প্রায় O(1)-এ জানতে; মোট সময় সর্টের O(E log E).
  • DSU-এর সীমাবদ্ধতা? — split বা edge মোছা দক্ষভাবে হয় না; সব প্রশ্ন আগে জানা থাকলে উল্টো ক্রমে union করে offline সমাধান করা যায়।

৯ · AI-এর যুগে DSU — দশ লাইনের কোড, এক লাইনের ভুল

“Union-find লিখে দাও” — যেকোনো AI সহকারী নিখুঁত দেখতে দশ লাইন দেবে। কিন্তু DSU-এর ভুল প্রায় কখনো compile error দেয় না, আর ছোট পরীক্ষায় উত্তরও প্রায়ই ঠিক আসে। ভুল লুকিয়ে থাকে এক লাইনে: union-এ root-এর বদলে সরাসরি p[a] = b, size হালনাগাদ ভুল root-এ, বা find-এর ফল ব্যবহার না করে পুরোনো p[x]-কে প্রতিনিধি ধরা। আর complexity-র প্রশ্নে AI প্রায়ই আত্মবিশ্বাসের সাথে “প্রতিটি কাজ O(1)” বলে, “amortized” আর “α” দুটোই বাদ দিয়ে।

DSU-এর কোডে AI সাধারণত যেসব ভুল করে

AI-এর ভুলকীভাবে ধরবেন
union-এ p[a] = b — root নয়, সরাসরি উপাদানunion(0,1), union(0,2) চালিয়ে দেখুন 1 আর 2 একই দলে কি না; প্রায়ই “না” আসবে
connected পরীক্ষায় p[a] == p[b]গভীরতা ২-এর একটি tree বানান; একই দলের দুজনের parent আলাদা হতে পারে
size যোগ হয় নিচে যাওয়া root-এ, বা swap-এর আগেপ্রতিটি union-এর পর সব root-এর sz-এর যোগফল n কি না assert করুন
Python-এ recursive find, balancing ছাড়া১০৫ টি উপাদানের শিকলে চালান — RecursionError
Parity DSU-তে compression-এর সময় parity না জমানোদীর্ঘ শিকলে একই প্রশ্ন দুবার করুন; দ্বিতীয়বার উত্তর বদলালে ভুল
“DSU প্রতিটি কাজ O(1)” বা “O(log n) amortized সবসময়”সঠিক বাক্য চান: m টি কাজে O(m α(n)), একক কাজ O(log n) পর্যন্ত
Directed গ্রাফে cycle ধরতে DSU0→1, 2→1, 0→2 — cycle নেই, কিন্তু DSU “cycle” বলবে

AI-কে কোড লিখে দিতে না বলে, আপনার কোড আর trace পরীক্ষা করতে বলুন — আর প্রতিটি দাবির প্রমাণ চান:

আমি DSA শিখছি, এখন disjoint set union. নিচে আমার লেখা DSU (union by size + path compression) আর একটি ইনপুটে আমার হাতে করা trace দিলাম: [এখানে কোড আর trace পেস্ট করুন] পুরো সমাধান নতুন করে লিখে দিও না। শুধু এগুলো করো: ১. n = 6-এ union(0,1), union(2,3), union(4,5), union(1,3), union(5,3) চালিয়ে প্রতিটি ধাপের পর parent আর size অ্যারে দেখাও, আর আমার trace-এর সাথে কোথায় অমিল বলো। ২. আমার union কি সবসময় root-কেই জোড়ে, আর size কি ঠিক root-এ যোগ হয়? একটি প্রতি-উদাহরণ খুঁজে দেখো। ৩. union by size-এ উচ্চতা ≤ log₂ n — এর প্রমাণটি ধাপে ধাপে লেখো, আর বলো path compression যোগ করলে প্রমাণের কোন অংশ বদলায়। ৪. আমার কোডের m টি কাজের মোট time complexity কত — "amortized" শব্দটি কেন লাগে, তা একটি উদাহরণ দিয়ে বুঝিয়ে বলো।

DSU-এর কোড যাচাইয়ের চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।

প্র ০১Union-find-এর জন্ম ১৯৬০-এর দশকের একটি compiler সমস্যা থেকে — FORTRAN-এর EQUIVALENCE ঘোষণার সাথে এর সম্পর্ক কী?

১৯৫০-এর দশকের শেষে আর ১৯৬০-এর শুরুতে FORTRAN ছিল বিজ্ঞানী আর প্রকৌশলীদের প্রধান প্রোগ্রামিং ভাষা। তখন মেমরি অত্যন্ত দামি, তাই FORTRAN-এ একটি বিশেষ ঘোষণা ছিল — EQUIVALENCE: প্রোগ্রামার বলতে পারতেন “এই দুটি ভেরিয়েবল আসলে মেমরির একই জায়গা ব্যবহার করবে”. যেমন EQUIVALENCE (A, B), (B, C), (D, E) লিখলে A, B, C একই ঘরে, আর D, E আরেকটিতে। Compiler-কে এই ঘোষণাগুলো পড়ে বের করতে হতো কোন কোন নাম একই শ্রেণিতে পড়ে — ঠিক equivalence relation-এর শ্রেণিগুলো খোঁজা। বড় বৈজ্ঞানিক প্রোগ্রামে এমন ঘোষণা হাজারে হাজারে থাকতে পারত, আর compiler-এর হাতেও মেমরি আর সময় ছিল কম।

১৯৬৪ সালে Communications of the ACM-এ Bernard Galler আর Michael Fischer “An improved equivalence algorithm” প্রকাশ করেন। সেখানেই আজকের parent pointer-এর forest-এর ধারণা: প্রতিটি শ্রেণিকে একটি tree হিসেবে রাখা, যেখানে প্রতিটি node শুধু ওপরের দিকে নির্দেশ করে, আর দুটি শ্রেণি জোড়া মানে এক root-কে অন্যটির নিচে বসানো। আকার ধরে জোড়ার ধারণাও খুব শুরুর দিকেই আসে, আর path compression-এর কৃতিত্ব সাধারণত দেওয়া হয় একই সময়ের কয়েকজন গবেষককে — Aho, Hopcroft ও Ullman তাঁদের ক্লাসিক বইয়ে McIlroy আর Morris-এর নাম উল্লেখ করেন। মজার ব্যাপার হলো, কৌশলগুলো খুব সরল হলেও তাদের মিলিত শক্তির সঠিক বিশ্লেষণ পেতে আরও প্রায় এক দশক লাগে।

গল্পটির শিক্ষা দুটি। প্রথমত, অনেক মৌলিক ডেটা স্ট্রাকচার জন্মেছে বিমূর্ত গণিত থেকে নয়, খুব বাস্তব প্রকৌশল-সমস্যা থেকে — এখানে compiler-এর একটি ঘোষণা সামলাতে গিয়ে। দ্বিতীয়ত, একটি ডেটা স্ট্রাকচার কত ভালো, তা জানতে কখনো কখনো তার আবিষ্কারের চেয়ে অনেক বেশি পরিশ্রম লাগে। আজ EQUIVALENCE প্রায় কেউ ব্যবহার করেন না, কিন্তু সেই সমাধান এখন Kruskal থেকে ছবি-প্রক্রিয়াকরণ পর্যন্ত সবখানে। Compiler-এর জগতেও DSU রয়ে গেছে: type inference-এ দুটি অজানা type “একই” বলে প্রমাণ হলে তাদের union করা হয় (প্রশ্ন ০৫).

মূল উপলব্ধি: “কোন জিনিসগুলো একই শ্রেণির” — এই প্রশ্নটি এত সাধারণ যে একটি compiler-এর ছোট সমস্যার সমাধান পুরো কম্পিউটার বিজ্ঞানের একটি মৌলিক হাতিয়ার হয়ে উঠেছে।

প্র ০২O(α(n)) প্রায় ধ্রুবক — তাহলে গবেষকরা কেন দশকের পর দশক এটি নিয়ে লেগে ছিলেন, আর O(1) কেন সম্ভব নয়?

Union by rank আর path compression আলাদাভাবে বোঝা সহজ: একটি উচ্চতা log n-এ আটকায়, অন্যটি হাঁটা পথ চ্যাপ্টা করে। কিন্তু দুটি একসাথে দিলে ঠিক কত দ্রুত — এই প্রশ্নটি অবাক করার মতো কঠিন হয়ে দাঁড়ায়। সমস্যা হলো, path compression tree-র আকার এমনভাবে বদলায় যে কোনো একটি কাজের খরচ আলাদা করে বলা যায় না; বিশ্লেষণ করতে হয় পুরো ক্রমের ওপর, যেখানে আজকের দামি find কালকের find-গুলোকে সস্তা করে।

১৯৭৩ সালে Hopcroft আর Ullman প্রথম শক্ত ফল দেন: m টি কাজে O(m log* n). log* এত ধীরে বাড়ে যে বাস্তবে ৫-এর বেশি নয়, তাই এটিই প্রায় সবাইকে সন্তুষ্ট করতে পারত। কিন্তু প্রশ্ন রয়ে গেল — এটিই কি আসল উত্তর, নাকি বিশ্লেষণটি শুধু দুর্বল? ১৯৭৫ সালে Robert Tarjan Journal of the ACM-এ “Efficiency of a Good But Not Linear Set Union Algorithm” প্রকাশ করেন। শিরোনামটিই গল্প বলে: অ্যালগরিদমটি “ভালো, কিন্তু রৈখিক নয়”. তিনি দেখান যে সময় Θ(m α(m, n)), যেখানে α হলো Ackermann ফাংশনের উল্টো — log*-এর চেয়েও অকল্পনীয় ধীরে বাড়া। আর ১৯৭৯ সালে তিনি প্রমাণ করেন, pointer machine নামের একটি স্বাভাবিক মডেলে এমন যেকোনো অ্যালগরিদমকে এই খরচ দিতেই হবে।

Ackermann ফাংশন নিজেই আরেক ইতিহাস। ১৯২৮ সালে জার্মান গণিতবিদ Wilhelm Ackermann এটি দিয়েছিলেন একটি সম্পূর্ণ ভিন্ন প্রশ্নের উত্তরে: এমন একটি ফাংশন, যা গণনা করা যায় (computable), কিন্তু শুধু সাধারণ for-লুপ ধাঁচের পুনরাবৃত্তি (primitive recursion) দিয়ে লেখা যায় না। এটি এত দ্রুত বাড়ে যে কোনো নির্দিষ্ট গভীরতার নেস্টেড লুপ একে ধরতে পারে না। গণিতের সেই বিমূর্ত বস্তুটি প্রায় অর্ধ শতাব্দী পর একটি ডেটা স্ট্রাকচারের জটিলতায় হাজির হলো — কম্পিউটার বিজ্ঞানের একটি সুন্দর কাকতালীয় মিল।

শেষ ধাপটি আসে ১৯৮৯ সালে: Michael Fredman আর Michael Saks cell probe মডেলে — যেখানে শুধু মেমরির কয়টি ঘর পড়া হলো তা গোনা হয়, হিসাবের বাকি সব বিনামূল্যে — দেখান যে union-find-এর যেকোনো সমাধানকে amortized Ω(α(n)) দিতেই হবে। অর্থাৎ O(1) শুধু আমাদের অজানা নয়, অসম্ভব। (তবে কোন দল কার সাথে জুড়বে তার কাঠামো আগে থেকে জানা থাকলে — Gabow ও Tarjan ১৯৮৫ সালে এমন একটি বিশেষ ক্ষেত্র দেখান — রৈখিক সময় সম্ভব।)

মূল উপলব্ধি: বাস্তবে α(n) একটি ছোট ধ্রুবক, কিন্তু তত্ত্বে এটি একটি নিখুঁত, অলঙ্ঘনীয় সীমা। পরীক্ষায় “O(1)” লিখবেন না — লিখুন “amortized O(α(n)), যা বাস্তবে ধ্রুবকের মতো”।

প্র ০৩কনটেস্টে অনেকে শুধু path compression লেখেন, union by size বাদ দিয়ে — এটি কতটা নিরাপদ, আর কখন উল্টোটা (শুধু union by size) ভালো?

কনটেস্টের অনেক কোডে DSU মাত্র দুই লাইন: int find(int x){ return p[x]==x ? x : p[x]=find(p[x]); } আর p[find(a)] = find(b);. কোনো size নেই, rank নেই। এটি কি চলে? তত্ত্ব বলে: শুধু path compression দিলে n টি make-set আর f টি find-এ মোট সময় Θ(n + f · log(2 + f/n) n) — Tarjan আর van Leeuwen ১৯৮৪ সালে বিভিন্ন রূপের এমন বিশ্লেষণ একসাথে করেন। f প্রায় n হলে এটি প্রায় O(n log n), আর f অনেক বড় হলে log-এর ভিত্তি বড় হয়ে খরচ আরও কমে। অর্থাৎ worst case-এ union by size-এর সমান, α নয়। বাস্তবের বেশিরভাগ ইনপুটে পার্থক্য প্রায় চোখে পড়ে না, তাই অনেকে ঝুঁকিটি নেন।

কিন্তু দুটি বিপদ আছে। প্রথমত, recursion-এর গভীরতা: balancing না থাকলে tree একটি লম্বা শিকল হতে পারে (অংশ ৩-এর ডায়াগ্রাম), আর তার নিচের node-এ প্রথম recursive find n গভীর recursion করবে। ১০৬ উপাদানে কিছু judge-এর ছোট stack-এ এটি stack overflow, আর Python-এ নিশ্চিত RecursionError. Union by size দিলে গভীরতা ২০-এর নিচে থাকে, অথবা path halving-এর লুপ ব্যবহার করলে recursion-ই থাকে না। দ্বিতীয়ত, কিছু সমস্যার প্রশ্নকর্তা ইচ্ছে করে এমন ক্রম দেন যাতে compression-বিহীন অংশটি ধীর হয়। সংক্ষেপে, size যোগ করতে তিন লাইন লাগে — নিশ্চয়তার জন্য দামটি সামান্য।

উল্টো ক্ষেত্রও আছে, যেখানে path compression বাদ দেওয়াই ঠিক। Rollback DSU-তে (আরও গভীরে বাক্সটি দেখুন) compression একসাথে অনেক parent বদলায়, যা ফেরানো ব্যয়বহুল। Persistent DSU-তে (পুরোনো সংস্করণগুলোও রাখতে হয়) একই সমস্যা। আর যেখানে প্রতিটি কাজের worst-case সময় জরুরি (real-time), সেখানে union by size-এর “প্রতিটি কাজ O(log n), নিশ্চিত” amortized α-র চেয়ে বেশি কাজের। বাস্তব প্রকৌশলে দ্রুততম রূপ সাধারণত union by rank বা size আর path halving — এক পাসের লুপ, recursion নেই, cache-এ ভালো।

একটি সূক্ষ্ম বিষয়: path compression rank-কে উচ্চতা থেকে আলাদা করে দেয়, কিন্তু কেউ rank কমায় না। এটি ভুল নয় — Tarjan-এর বিশ্লেষণ ঠিক এই “পুরোনো” rank-এর ওপরই দাঁড়িয়ে, কারণ rank সবসময় উচ্চতার একটি ওপরের সীমা আর 2rank ≤ আকার সত্য থাকে।

মূল উপলব্ধি: দুই কৌশল দুই ভিন্ন জিনিস দেয় — balancing দেয় worst-case নিশ্চয়তা আর ছোট recursion, compression দেয় amortized গতি। কোনটি লাগবে তা নির্ভর করে সমস্যা undo চায় কি না, আর আপনার judge-এর stack কত বড়।

প্র ০৪পদার্থবিজ্ঞানের percolation গবেষণায় union-find কীভাবে ঢুকে পড়ল, আর Hoshen–Kopelman পদ্ধতি কী?

Percolation তত্ত্ব শুরু হয় ১৯৫৭ সালে Simon Broadbent আর John Hammersley-এর কাজ দিয়ে — মূল প্রশ্ন ছিল গ্যাস-মুখোশের কার্বনের ছিদ্রের মধ্য দিয়ে গ্যাস কতটা ঢুকতে পারে। সরল মডেল: একটি বড় বর্গ গ্রিডের প্রতিটি ঘর p সম্ভাবনায় খোলা। খোলা ঘরগুলো পাশাপাশি থাকলে জুড়ে একটি “cluster” বানায়। p ছোট হলে cluster-গুলো ছোট ছোট দ্বীপ; p বাড়তে বাড়তে একটি নির্দিষ্ট মানে হঠাৎ এমন একটি বিশাল cluster দেখা দেয়, যা গ্রিডের এক প্রান্ত থেকে অন্য প্রান্তে পৌঁছায়। এই হঠাৎ পরিবর্তন — phase transition — কফি ফিল্টারে পানি চুইয়ে পড়া থেকে বনের আগুন ছড়ানো, ভূগর্ভের তেল বা মহামারির বিস্তার পর্যন্ত অনেক কিছুর মডেল।

বর্গ গ্রিডে ঘর-ভিত্তিক (site) percolation-এর সেই সীমার কোনো সূত্র জানা নেই; শুধু কম্পিউটারে অসংখ্য এলোমেলো গ্রিড বানিয়ে মাপা হয় — Monte Carlo পদ্ধতি। মাপা মানটি প্রায় 0.5927. কিন্তু প্রতিটি গ্রিডে সব cluster খুঁজে বের করা কঠিন, কারণ গবেষকরা কোটি কোটি ঘরের গ্রিড চান, আর তখনকার কম্পিউটারের মেমরি সীমিত। ১৯৭৬ সালে Joseph Hoshen আর Raoul Kopelman একটি পদ্ধতি প্রকাশ করেন, যা আজ Hoshen–Kopelman অ্যালগরিদম নামে পরিচিত: গ্রিড এক সারি করে পড়ুন, প্রতিটি খোলা ঘরকে তার বাঁ আর ওপরের প্রতিবেশীর লেবেল দিন, আর দুই ভিন্ন লেবেল মিললে তাদের “একই” বলে চিহ্নিত করুন — ঠিক union. লেবেলগুলো একটি parent-ধাঁচের অ্যারেতে থাকে, আর root-এ cluster-এর আকার। আসলে এটি পুরোপুরি union-find, শুধু পদার্থবিজ্ঞানের ভাষায় নতুন করে আবিষ্কৃত।

একই ধারণা আগেই ছবি-প্রক্রিয়াকরণে এসেছিল: Azriel Rosenfeld আর John Pfaltz ১৯৬৬ সালে ছবির connected component চিহ্নিত করার দুই-পাস পদ্ধতি দেন, যেখানে প্রথম পাসে লেবেলের সমতা (equivalence) জমা হয়, দ্বিতীয় পাসে সমাধান হয়। আর Robert Sedgewick ও Kevin Wayne-এর জনপ্রিয় Algorithms বইয়ে union-find শেখানোই হয় percolation দিয়ে — দুটি কাল্পনিক “ওপর” আর “নিচ” node রেখে, ঠিক এই পর্বের অংশ ৭-এর মতো।

গল্পটি দেখায় কীভাবে একটি ভালো ডেটা স্ট্রাকচার একাধিক ক্ষেত্রে স্বাধীনভাবে আবিষ্কৃত হয়: compiler-এ, ছবি-প্রক্রিয়াকরণে, পদার্থবিজ্ঞানে। প্রতিবার নাম আলাদা, কিন্তু মূল কাঠামো একই — পরস্পর-বিচ্ছিন্ন দল, যারা শুধু জোড়া লাগে।

মূল উপলব্ধি: যেখানেই “পাশাপাশি জিনিসগুলো মিলে কয়টি দল” প্রশ্ন আছে — pixel, মাটির ছিদ্র বা গ্রামের রাস্তা — সেখানেই union-find. ক্ষেত্রের নাম বদলালেও অ্যালগরিদম চেনা শিখলে আপনি অনেক “নতুন” সমস্যা এক নজরে সমাধান করতে পারবেন।

প্র ০৫Kruskal-এর MST ছাড়াও বড় সফটওয়্যারে union-find কোথায় চলে — compiler-এর type inference থেকে প্রোগ্রাম বিশ্লেষণ পর্যন্ত?

সবচেয়ে চেনা ব্যবহার MST. Otakar Borůvka ১৯২৬ সালে মোরাভিয়ায় বিদ্যুৎ লাইনের নেটওয়ার্ক সাশ্রয়ীভাবে বানানোর প্রশ্নে প্রথম MST অ্যালগরিদম দেন, আর Joseph Kruskal ১৯৫৬ সালে সর্ট করে একে একে edge নেওয়ার সরল পদ্ধতিটি প্রকাশ করেন। Kruskal-এর অ্যালগরিদমকে দ্রুত করার চাবিকাঠিই DSU. কিন্তু এর বাইরেও DSU এমন অনেক জায়গায় চলে, যা আমরা প্রতিদিন ব্যবহার করি অথচ দেখি না।

Type inference. ML, Haskell, OCaml বা Rust-এর মতো ভাষায় প্রোগ্রামার অনেক জায়গায় type লেখেন না — compiler নিজে বের করে। Hindley–Milner পদ্ধতিতে compiler প্রতিটি অজানা type-এর জন্য একটি চলক রাখে, আর কোড পড়তে পড়তে সমীকরণ পায়: “এই চলক আর ওই চলক একই type”. এই সমীকরণ মেলানোর কাজের নাম unification, আর দক্ষ unification-এর ভেতরে থাকে union-find: “একই” প্রমাণ হলে দুটি type-চলকের union, আর কোনো চলকের আসল type জানতে find.

Pointer analysis. বড় C প্রোগ্রামে কোন pointer কোন মেমরির দিকে নির্দেশ করতে পারে, তা জানা compiler optimization আর নিরাপত্তা-বিশ্লেষণের জন্য জরুরি। ১৯৯৬ সালে Bjarne Steensgaard “Points-to analysis in almost linear time” প্রকাশ করেন: দুটি pointer একই জায়গায় নির্দেশ করতে পারলে তাদের সম্ভাব্য লক্ষ্যগুলো union করে ফেলা হয়। নিখুঁততায় কিছুটা ছাড় দিয়ে এটি লাখো লাইনের প্রোগ্রামেও প্রায় রৈখিক সময়ে চলে — শিরোনামের “almost linear” কথাটি আসলে সেই α(n).

অন্যান্য. Electronic circuit-এর নকশায় কোন তারগুলো আসলে একই বৈদ্যুতিক node (net) তা বের করা; ছবি থেকে বস্তু আলাদা করার segmentation-এ (যেমন Felzenszwalb–Huttenlocher-এর গ্রাফ-ভিত্তিক পদ্ধতি) কাছাকাছি pixel-এর অঞ্চল জোড়া; বিশাল সামাজিক নেটওয়ার্ক বা ওয়েব-গ্রাফে connected component গোনা; আর ডুপ্লিকেট রেকর্ড মেলানো (entity resolution) — “এই দুটি গ্রাহকের তথ্য একই মানুষের” প্রমাণ হলে union.

তবে সীমাটিও মনে রাখুন: এই সব ব্যবহারে দল শুধু জোড়া লাগে। Edge মুছতেও হলে — যেমন নেটওয়ার্কের লিংক ভেঙে যাওয়া — দরকার fully dynamic connectivity; Holm, de Lichtenberg ও Thorup ২০০১ সালে এর জন্য amortized O(log² n)-এর একটি স্ট্রাকচার দেন, যা DSU-এর চেয়ে অনেক জটিল।

মূল উপলব্ধি: “সমতা জমা করা” — দুটি জিনিস একই, এই তথ্য একে একে আসা — compiler, circuit, ছবি আর ডেটাবেস সবখানে আছে। সেখানে union-find প্রায় বিনামূল্যের একটি হাতিয়ার।

অনুশীলন

খাতা-কলম নিন। প্রথম তিনটি হাতে করার, শেষটিতে কোড লিখতে হবে।

  1. হাতে trace করুন: n = 6 (0 থেকে 5), union by size (সমান হলে প্রথম argument-এর root parent) আর path compression. পরপর চালান union(0,1), union(2,3), union(4,5), union(1,3), union(5,3), তারপর find(5). প্রতিটি কাজের পর parent অ্যারে লিখুন।

    শুরু [0,1,2,3,4,5]. union(0,1) → p[1]=0: [0,0,2,3,4,5]. union(2,3) → [0,0,2,2,4,5]. union(4,5) → [0,0,2,2,4,4]. union(1,3): find(1) = 0, find(3) = 2, আকার ২ আর ২, সমান → p[2]=0: [0,0,0,2,4,4], sz[0] = 4.

    union(5,3): find(5) = 4 (আকার ২); find(3) হাঁটে 3 → 2 → 0, আর compression-এ p[3]=0. আকার ২ < ৪, তাই অদলবদল: 4 যায় 0-এর নিচে: [0,0,0,0,0,4], sz[0] = 6. find(5): 5 → 4 → 0, তারপর p[5]=0: [0,0,0,0,0,0]. লক্ষ করুন, union(5,3)-এর প্রথম argument 5 হলেও তার দল ছোট বলে নিচে গেছে — সমান হলেই কেবল argument-এর ক্রম গুরুত্বপূর্ণ।

  2. Offline কৌশল হাতে চালান: চারটি গ্রাম 0–3, চারটি রাস্তা: a = 0-1, b = 1-2, c = 2-3, d = 0-3. বন্যায় রাস্তাগুলো এই ক্রমে ভাঙে: b, d, a, c. প্রতিটি ভাঙার পর কয়টি component? শুধু DSU (union) দিয়ে উত্তর বের করুন।

    উল্টো দিক থেকে চলুন। সব রাস্তা ভাঙার পরের অবস্থা দিয়ে শুরু: কোনো রাস্তা নেই, component ৪ — এটি শেষ ভাঙার (c) পরের উত্তর। এবার ভাঙার উল্টো ক্রমে যোগ করুন: c যোগ (2-3 জুড়ল) → ৩ — এটি a ভাঙার পরের উত্তর। a যোগ (0-1) → ২ — d ভাঙার পরের উত্তর। d যোগ (0-3: {0,1} আর {2,3} জুড়ল) → ১ — b ভাঙার পরের উত্তর। (শেষে b যোগ করলে find(1) == find(2), একটি cycle; component ১ — এটি কোনো ভাঙার আগের অবস্থা।)

    উত্তর, ভাঙার ক্রমে: b-র পর ১, d-র পর ২, a-র পর ৩, c-র পর ৪. মোট খরচ O((n + m) α(n)) — প্রতিটি ভাঙার পর BFS চালানোর O(m(n + m))-এর তুলনায় অনেক কম।

  3. ভেবে বলুন: (ক) শুধু path compression (কোনো balancing নেই) দিয়ে একটি একক find-এর worst case কত হতে পারে? একটি উদাহরণ দিন। (খ) Rollback-যোগ্য DSU-তে path compression কেন বাদ দিতে হয়, কিন্তু union by size রাখা হয়?

    (ক) Θ(n). নিয়ম “p[find(a)] = find(b)” দিয়ে union(0,1), union(1,2), union(2,3), …, union(n−2, n−1) চালান। প্রতিটি union-এ a নিজেই তখন root (আগের union-এ সে সবে নতুন root হয়েছে), তাই find-এ হাঁটতে হয় না — compression-এর সুযোগই আসে না। শেষে শিকল 0 → 1 → … → n − 1. এরপর find(0) একাই n − 1 hop. তবে এই একটি find পুরো পথ চ্যাপ্টা করে দেয়, তাই পরের find-গুলো সস্তা — amortized খরচ ছোট থাকে, কিন্তু একক কাজের worst case বড়।

    (খ) Undo মানে সর্বশেষ পরিবর্তনগুলো উল্টো ক্রমে ফেরানো। একটি union ঠিক একটি parent (আর একটি size) বদলায় — stack-এ রাখা আর ফেরানো O(1). কিন্তু একটি compressed find পথের অনেক parent একসাথে বদলায়, আর সেগুলো কোনো union-এর অংশ নয় — সব রেকর্ড করে ফেরাতে গেলে amortized সুবিধা নষ্ট হয়। Union by size রাখা হয়, কারণ সেটিই compression ছাড়া উচ্চতা log₂ n-এ বেঁধে রাখে — ফলে প্রতিটি find O(log n) worst case, যা undo-এর পরেও সত্য থাকে।

  4. কোড লিখুন: একটি undirected গ্রাফের n টি vertex আর edge-এর একটি তালিকা দেওয়া আছে। DSU দিয়ে (ক) প্রথম যে edge একটি cycle বানায় তা, (খ) শেষে কয়টি component, আর (গ) সবচেয়ে বড় component-এর আকার বের করুন। n = 6, edges = (0,1), (1,2), (3,4), (2,0), (4,5) দিয়ে হাতে উত্তর মেলান।

    অংশ ৫-এর DSU struct হুবহু কাজে লাগে — unite false দিলে সেটিই cycle-এর edge, comps component গোনে, আর root-এর sz আকার দেয়।

    // uses struct DSU (p, sz, comps, find, unite) from the main example
    void analyse(int n, const vector<pair<int, int>>& edges) {
        DSU d(n);
        bool found = false;
        for (auto [u, v] : edges)
            if (!d.unite(u, v) && !found) {
                cout << "first cycle edge: " << u << '-' << v << '\n';
                found = true;
            }
        int best = 0;
        for (int x = 0; x < n; ++x)
            if (d.find(x) == x) best = max(best, d.sz[x]);   // only roots hold valid sizes
        cout << "components: " << d.comps << ", largest: " << best << '\n';
    }

    হাতে: (0,1), (1,2) জুড়ে {0,1,2}; (3,4) জুড়ে {3,4}; (2,0)-তে find(2) == find(0) — প্রথম cycle edge 2-0; (4,5) জুড়ে {3,4,5}. শেষে component ২, সবচেয়ে বড়টির আকার ৩. সময় O(n + E α(n)), জায়গা O(n). খেয়াল করুন, আকার পড়া হয়েছে শুধু root থেকে — non-root-এর sz পুরোনো মান রাখে।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমunion by size আর path compression-সহ DSU-তে union(a, b)-এর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. find(a) দিয়ে a-র root ra বের করা (পথটি compress করে)
  2. find(b) দিয়ে b-র root rb বের করা
  3. ra আর rb একই হলে কিছু না করে থামা — a আর b আগেই একই দলে
  4. দুই root-এর মধ্যে যার size ছোট, তাকে বড়টির নিচে বসানো (parent বদলানো)
  5. ওপরের root-এর size-এ নিচের root-এর size যোগ করা আর component-এর সংখ্যা এক কমানো

১৬টি উপাদানের একটি DSU-তে শুধু union by size ব্যবহার করা হয় (path compression নেই)। কোনো tree-র উচ্চতা সর্বোচ্চ কত হতে পারে?

  • 15
  • 8
  • 4
  • 2

একটি DSU-র parent অ্যারে p = [0, 0, 1, 2, 3] (0-based, 0 হলো root). full path compression-সহ find(4) চালানোর পর অ্যারেটি কী হবে?

  • [0, 0, 0, 0, 0]
  • [0, 0, 0, 2, 2]
  • [0, 0, 1, 2, 0]
  • [0, 0, 1, 2, 4]

Union by rank আর path compression দুটোই থাকা DSU-তে n টি উপাদানে m টি কাজ সম্পর্কে কোন বক্তব্যটি সঠিক?

  • প্রতিটি কাজ worst case-এ O(1)
  • মোট সময় Θ(m log n), এর চেয়ে ভালো নয়
  • একটি একক find O(n) হতে পারে, কিন্তু গড়ে O(1)
  • মোট সময় O(m α(n)), কিন্তু একটি একক কাজ O(log n) পর্যন্ত নিতে পারে

একটি undirected গ্রাফের edge-গুলো একে একে DSU-তে দেওয়া হচ্ছে। কোন edge (u, v)-তে find(u) == find(v) পাওয়া গেলে এর অর্থ কী?

  • u আর v আলাদা component-এ, তাই edge-টি তাদের জোড়ে
  • এই edge একটি cycle বানায়
  • গ্রাফটি এখন পুরো connected
  • গ্রাফটি bipartite নয়

নিচের কোন কাজটি সাধারণ disjoint set union (union by rank + path compression) দক্ষভাবে করতে পারে না?

  • একটি দলকে দুই ভাগে ভাঙা (split) বা একটি edge মুছে ফেলা
  • একটি উপাদানের দলের সদস্য-সংখ্যা বলা
  • এখন মোট কয়টি দল আছে, তা বলা
  • দুটি উপাদান একই দলে কি না, তা বলা

Parity DSU-তে (par[x] = x আর তার parent একই পক্ষে হলে 0, উল্টো পক্ষে হলে 1) find(a) = (r, 1) আর find(b) = (r, 1) পাওয়া গেল। নতুন দাবি “a আর b প্রতিপক্ষ” সম্পর্কে কী বলা যায়?

  • দুই দল আলাদা, তাই দাবিটি নতুন তথ্য হিসেবে জমা হবে
  • দাবিটি স্ববিরোধী, কারণ 1 XOR 1 = 0 — তারা একই পক্ষে
  • দাবিটি সামঞ্জস্যপূর্ণ, কারণ দুজনেই root-এর প্রতিপক্ষ
  • যথেষ্ট তথ্য নেই — আরও find লাগবে

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. DSU = পরস্পর-বিচ্ছিন্ন দল, যারা শুধু জোড়া লাগে; কাজ make-set, find, union; একই দলে কি না = find(a) == find(b).
  2. Forest-এ প্রতিটি উপাদান শুধু p[x] রাখে; root = প্রতিনিধি; union মানে দুই root জোড়া, উপাদান নয়।
  3. Union by size/rank: ছোট tree বড়টির নিচে → উচ্চতা ≤ ⌊log₂ n⌋ (গভীরতা বাড়লে দল দ্বিগুণ)।
  4. Path compression (বা halving/splitting) + balancing → m টি কাজে O(m α(n)) amortized; α(n) ≤ 4 বাস্তবে, কিন্তু O(1) নয়।
  5. প্রয়োগ: Kruskal, component গোনা, undirected cycle, offline (উল্টো ক্রমে union), percolation, ছবির লেবেল, parity/weighted DSU; সীমা: split বা মোছা নয়.

পরের পর্বে: নতুন মডিউল — রেঞ্জ কোয়েরি। একটি অ্যারের যেকোনো অংশের যোগফল, সর্বনিম্ন বা সর্বোচ্চ, মাঝে মাঝে মান বদলালেও — প্রতিটি প্রশ্ন O(log n)-এ। Segment tree.

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান