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

Linked list — singly, doubly আর circular

Linked Lists — Nodes & Pointers, Insert/Delete, Sentinels, Doubly & Circular Lists, Reversal and Floyd's Cycle Detection

Taught in Bangla, this lesson builds linked lists from nodes and pointers using box-and-arrow diagrams. It covers traversal, search, insertion and deletion with complexities, dummy sentinel heads, doubly and circular lists, arrays versus linked lists and cache effects, memory management in C++ and Python, and classic problems: reversal, middle node, merging sorted lists, removing the nth from end and Floyd's cycle detection.

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

  • Node আর pointer দিয়ে linked list কীভাবে গড়া হয়, বাক্স আর তীর এঁকে ভাবতে পারা
  • Singly list-এ traversal, search, শুরুতে ও মাঝে insert, delete — প্রতিটির সঠিক complexity সহ
  • Dummy head, doubly list আর circular list কোথায় কাজে লাগে (std::list, round-robin, Josephus)
  • Array বনাম linked list — Big-O-র তুলনা আর cache-এর কারণে বাস্তবের ভিন্ন চিত্র; C++-এর মেমরি দায়িত্ব বনাম Python-এর garbage collector
  • List উল্টানো, মাঝখান খোঁজা, Floyd-এর cycle detection (প্রমাণসহ), দুটি sorted list জোড়া আর শেষ থেকে n-তম node মোছা

১ · Linked list কী — ট্রেনের বগির মতো node আর pointer

কমলাপুর থেকে চট্টগ্রামগামী একটি ট্রেনের কথা ভাবুন। ইঞ্জিনের পেছনে একটার পর একটা বগি। প্রতিটি বগি শুধু জানে তার ঠিক পরের বগিটি কোনটি — আংটা দিয়ে জোড়া। মাঝে একটি নতুন বগি জুড়তে হলে পুরো ট্রেন সরাতে হয় না: শুধু একটি আংটা খুলে নতুন বগিটি ঢুকিয়ে দুই দিকে আংটা লাগালেই হলো। আবার ১২ নম্বর বগিতে যেতে চাইলে ইঞ্জিন থেকে হেঁটে হেঁটে গুনতে হয় — সরাসরি “১২ নম্বরে লাফ দেওয়ার” কোনো উপায় নেই।

এটাই linked list. পর্ব ১০-এ দেখেছেন, array-র সব উপাদান মেমরিতে পাশাপাশি বসে থাকে, তাই ঠিকানার হিসাব করে যেকোনো ঘরে সরাসরি যাওয়া যায়। Linked list-এ উপাদানগুলো মেমরির যেখানে খুশি থাকতে পারে; প্রতিটি উপাদান নিজের সাথে রাখে পরেরটির ঠিকানা। প্রতিটি উপাদানকে বলে node, আর সেই ঠিকানাকে বলে pointer (Python বা Java-তে একে reference বলে — কাজ একই)।

📦 Node — একটি বগি

দুটি অংশ: val (যে ডেটা রাখছি — একটি সংখ্যা, নাম, বা যেকোনো কিছু) আর next (পরের node-এর ঠিকানা)। ছবিতে আঁকি [10]→ এভাবে।

মান + পরের ঠিকানা

🚂 Head — ইঞ্জিন

প্রথম node-এর ঠিকানা একটি আলাদা ভ্যারিয়েবলে রাখা হয়, নাম সাধারণত head. পুরো list-এর কাছে পৌঁছানোর একমাত্র দরজা এটি। head হারালে পুরো list হারাল।

list-এর প্রবেশপথ

🛑 null — শেষ বগি

শেষ node-এর next-এ কোনো ঠিকানা থাকে না — থাকে একটি বিশেষ মান, null pointer (C++-এ nullptr, Python-এ None). খালি list মানে head নিজেই null.

“এখানেই শেষ”

মেমরিতে আসলে কী ঘটে, একটি উদাহরণে দেখুন। ধরুন তিনটি node — 10, 20, 30 — মেমরির তিনটি এলোমেলো জায়গায় তৈরি হয়েছে। ঠিকানাগুলো কাল্পনিক, কিন্তু ধারণাটি আসল:

head = 0x7a40

ঠিকানা   val   next
0x7a40   10    0x91c8   ──┐
0x91c8   20    0x6010   ←─┘ ──┐
0x6010   30    null     ←─────┘

ছবিতে:  head → [10] → [20] → [30] → null

লক্ষ করুন, ঠিকানাগুলো ক্রমানুসারে নেই — 0x6010 আসলে 0x7a40-এর আগে। তাতে কিছু যায় আসে না; ক্রম ঠিক করে next-এর শিকল। এই নমনীয়তাই linked list-এর শক্তি, আর (পরে দেখবেন) এটাই তার দুর্বলতাও। কোডে একটি node লিখতে লাগে মাত্র একটি ছোট struct বা class:

Pseudocode (0-based গণনা; NIL = null)

NODE:
    val          // the data
    next         // address of the next NODE, or NIL

LENGTH(head):
    count = 0
    cur = head
    while cur != NIL:
        count = count + 1
        cur = cur.next
    return count

C++-এ struct Node { int val; Node* next; }; — এখানে Node* মানে “একটি Node-এর ঠিকানা”। Python-এ একটি ছোট class, যার next অ্যাট্রিবিউটে আরেকটি Node বস্তু বা None. Node-গুলো তৈরি হয় চলার সময়, প্রয়োজনমতো, heap memory থেকে — তাই list আগে থেকে আকার না জেনেই বাড়তে-কমতে পারে।

Linked list-এর তিনটি প্রধান রূপ আছে, এই পর্বে তিনটিই দেখব: singly linked list (প্রতিটি node শুধু পরেরটি চেনে), doubly linked list (আগে-পরে দুটোই চেনে) আর circular linked list (শেষ node আবার প্রথমটিতে ফেরে)।

২ · List-এ হাঁটা — traversal আর search

Linked list-এর প্রায় সব কাজের ভিত্তি একটি লুপ: head থেকে শুরু করে একটি pointer (নাম দিই cur, “current”) একটার পর একটা node-এ সরানো, যতক্ষণ না null আসে। একে বলে traversal. list ছাপা, দৈর্ঘ্য গোনা, যোগফল বের করা, কিছু খোঁজা — সব এই এক ধাঁচ।

খোঁজার কাজটি দেখুন। list-টি sorted হলেও এখানে binary search চলে না, কারণ মাঝের node-এ পৌঁছাতেই অর্ধেক পথ হাঁটতে হয়। তাই linear search-ই একমাত্র উপায়। নিচে প্রথমে 30 খুঁজব (পাওয়া যায়), তারপর 35 (পাওয়া যায় না):

1Node* search(Node* head, int x) {2    for (Node* cur = head; cur != nullptr; cur = cur->next)3        if (cur->val == x) return cur;   // found4    return nullptr;                      // not in the list5}
ভ্যারিয়েবল
x (যা খুঁজছি)
cur
মোট তুলনা
ফেরত মান
List↑ = cur এখন যেখানে

                    
  1. শুরু: cur = head. cur এখন প্রথম node [10]-এ। 10 ≠ 30, তাই লুপের শেষ অংশ cur = cur->next চলবে। তুলনা ১টি।
  2. এক ধাপ এগোনো। cur এখন [20]. 20 ≠ 30. খেয়াল করুন, cur সরাতে আমরা [10]-এর ভেতরে রাখা ঠিকানাটি পড়েছি — linked list-এ এগোনোর এটিই একমাত্র উপায়। তুলনা ২টি।
  3. পাওয়া গেছে। cur = [30], আর 30 == 30. ফাংশন সঙ্গে সঙ্গে এই node-এর ঠিকানা ফেরত দেয়। ৩টি তুলনা লাগল, কারণ 30 ছিল তৃতীয় node.
  4. নতুন খোঁজ: 35. আবার head থেকে শুরু। 10, 20, 30 — তিনটির কোনোটিই 35 নয়, cur এখন শেষ node [40]-এ। 40 ≠ 35. তুলনা ৪টি।
  5. null-এ পৌঁছানো। [40]-এর next হলো null, তাই cur = nullptr আর লুপের শর্ত cur != nullptr মিথ্যা। লাইন ৪ ফেরত দেয় nullptr — “নেই”। না-পাওয়া খোঁজে সব n টি node দেখতে হলো: worst case Θ(n).

বিশ্লেষণ। লুপ প্রতিটি node-এ একবার থামে, প্রতিবার O(1) কাজ (একটি তুলনা, একটি pointer পড়া)। তাই traversal Θ(n), search worst case-এ Θ(n), আর k-তম node-এ পৌঁছানো Θ(k). বাড়তি জায়গা শুধু একটি pointer: O(1). array-তে a[k] O(1) — এই random access-এর সুবিধা linked list-এ নেই, আর এটিই তার সবচেয়ে বড় দাম।

সবচেয়ে সাধারণ crash: null-এর ভেতরে ঢোকার চেষ্টা। cur->next->val লেখার আগে নিশ্চিত হোন cur আর cur->next দুটোই null নয়। C++-এ null pointer-এর ভেতরে পড়া undefined behavior — সাধারণত Segmentation fault; Python-এ AttributeError: 'NoneType' object has no attribute 'next'. খালি list (head = null) আর এক node-এর list — এই দুটি edge case দিয়ে প্রতিটি linked list ফাংশন অবশ্যই পরীক্ষা করুন।

৩ · Insert আর delete — শুধু কয়েকটি তীর বদলানো

Array-র মাঝে একটি উপাদান ঢোকাতে হলে তার ডানের সবাইকে এক ঘর সরাতে হয় — O(n). Linked list-এ কেউ সরে না; শুধু তীর (pointer) নতুন করে আঁকা হয়। ট্রেনের উদাহরণে, নতুন বগি জুড়তে মাত্র দুটি আংটা বদলাতে হয়। তিনটি মূল কাজ:

  • শুরুতে যোগ (push front): নতুন node-এর next = পুরোনো head; তারপর head = নতুন node. O(1).
  • p-র পরে যোগ (insert after): নতুন node-এর next = p-র next; তারপর p-র next = নতুন node. O(1) — যদি p আগে থেকেই হাতে থাকে।
  • prev-এর পরেরটি মোছা (delete after): prev-এর next = মুছতে চাওয়া node-এর next; তারপর সেই node-এর মেমরি ফেরত। O(1) — কিন্তু মুছতে চাওয়া node-এর আগেরটি হাতে থাকতে হবে।

নিচের ডায়াগ্রামে head → [20] → [40] list-এ এই তিনটি কাজ পরপর দেখুন। ক্রমটি খুব মনোযোগ দিয়ে লক্ষ করুন — তীর বদলানোর ক্রম ভুল হলে list ভেঙে যায়।

1struct Node { int val; Node* next; };2// 1) insert at head: O(1)3Node* n = new Node{10, head};   // n->next = head4head = n;5// 2) insert after p: O(1)6n = new Node{30, p->next};      // n->next = p->next7p->next = n;8// 3) delete the node after prev: O(1)9Node* t = prev->next;10prev->next = t->next;           // unlink11delete t;                        // free the memory
অবস্থা
head
n
p / prev
head থেকে পৌঁছানো যায়
বাক্স আর তীর↓ ↑ = কোন ভ্যারিয়েবল কোথায় দেখায়

                    
  1. শুরুর list. head দেখায় [20]-এ, [20] → [40] → null. মোট ২টি node. প্রথম কাজ: শুরুতে 10 যোগ করা।
  2. নতুন node, আগে তার তীর। new Node{10, head} heap-এ নতুন node বানায় আর তার next-এ বসায় পুরোনো head, অর্থাৎ [20]. list-এ এখনো কিছু বদলায়নি — head এখনো [20]-এ, কিন্তু [10] ইতিমধ্যে শিকলের সামনে জুড়ে আছে।
  3. head সরানো। head = n. এবার list: [10] → [20] → [40]. মাত্র দুটি pointer লেখা, list-এর আকার যত বড়ই হোক — তাই O(1). ক্রম উল্টালে (আগে head = n) পুরোনো [20]-এর ঠিকানা হারিয়ে যেত, পুরো বাকি list হারাত!
  4. [20]-এর পরে 30. p ধরে আছে [20]-কে। নতুন node [30]-এর next-এ বসানো হলো p->next, অর্থাৎ [40]. এখন [20] আর [30] দুজনেই [40]-কে দেখায় — কোনো ক্ষতি নেই, কারণ [30] এখনো শিকলে ঢোকেনি।
  5. p-র তীর ঘোরানো। p->next = n. এখন [10] → [20] → [30] → [40] → null, ৪টি node. আবার নিয়মটি খেয়াল করুন: নতুন node-এর তীর আগে, পুরোনো node-এর তীর পরে।
  6. [20] মোছা — আগের node লাগবে। [20]-কে সরাতে [10]-এর তীর বদলাতে হবে, তাই prev = [10]. t = prev->next ধরে রাখে [20]-কে, আর prev->next = t->next [10]-কে সরাসরি [30]-এর সাথে জোড়ে। [20] এখনো [30]-কে দেখায়, কিন্তু head থেকে আর কেউ [20]-এ পৌঁছাতে পারে না।
  7. মেমরি ফেরত। delete t দিয়ে [20]-এর মেমরি heap-কে ফেরত দেওয়া হলো। list: [10] → [30] → [40], ৩টি node. C++-এ এই লাইন ভুলে গেলে memory leak (অংশ ৭); Python-এ এটি লিখতেই হয় না — garbage collector নিজেই সামলায়।

সারকথা: তীর বদলানো O(1), কিন্তু সঠিক জায়গায় পৌঁছানো সাধারণত O(n). “মান 30-এর node মুছুন” বললে আগে search করে তার আগের node খুঁজতে হয় — মোট O(n). তাই linked list-এর O(1) insert/delete-এর দাবি সত্য শুধু তখনই, যখন সংশ্লিষ্ট node (বা তার আগেরটি) আগে থেকেই হাতে আছে। পরীক্ষায় এই সূক্ষ্ম পার্থক্যটি প্রায়ই জিজ্ঞেস করা হয়।

শেষে যোগ (push back) কেমন? শুধু head থাকলে শেষ node খুঁজতে পুরো list হাঁটতে হয় — O(n). সমাধান সহজ: head-এর সাথে একটি tail pointer-ও রাখুন, যা সবসময় শেষ node দেখায়। তখন push back O(1) — tail->next = n; tail = n;. তবে শেষ node মুছতে singly list-এ tail থাকলেও O(n) লাগে, কারণ শেষের আগের node খুঁজতে হয় — এই সমস্যার সমাধান doubly list (অংশ ৫)।

৪ · Dummy (sentinel) head — বিশেষ ক্ষেত্র দূর করার কৌশল

অংশ ৩-এর delete-এ একটি প্রশ্ন লুকিয়ে ছিল: যদি head নিজেকেই মুছতে হয়? head-এর আগে তো কোনো node নেই, তাই “prev-এর তীর বদলাও” নিয়মটি চলে না — head ভ্যারিয়েবলটিই বদলাতে হয়। ফলে প্রায় প্রতিটি linked list ফাংশনে একটি আলাদা if (head ...) শাখা ঢুকে পড়ে, আর বাগের বড় অংশ জন্মায় ঠিক সেখানেই।

কৌশলটি হলো: আসল head-এর সামনে একটি নকল node বসিয়ে দিন, যার মান অর্থহীন। একে বলে dummy বা sentinel node. এখন প্রতিটি আসল node-এর — প্রথমটিরও — একটি আগের node আছে। সব ক্ষেত্র একই নিয়মে চলে, শেষে শুধু dummy.next ফেরত দিলেই নতুন head পাওয়া যায়।

ব্যাংকের লাইনে দাঁড়ানো প্রত্যেককে বলা হলো, “আপনার সামনের জনকে একটি টোকেন দিন।” সবার সামনে কেউ আছে — শুধু প্রথম জনের নেই, তাঁর জন্য আলাদা নিয়ম বানাতে হয়। এবার লাইনের একেবারে সামনে একটি খুঁটি পুঁতে দিন। এখন প্রথম জনের সামনেও “কেউ” আছে, আলাদা নিয়মের দরকার নেই। খুঁটিটি কোনো গ্রাহক নয় — এটিই sentinel.

উদাহরণ: list থেকে মান x-এর সব node মুছে ফেলুন। dummy ছাড়া লিখলে আগে head-এর জন্য একটি আলাদা লুপ লাগে (কারণ শুরুর কয়েকটি node-ই x হতে পারে)। dummy দিয়ে লিখলে একটিই লুপ:

// remove every node whose value is x; returns the (possibly new) head
Node* removeAll(Node* head, int x) {
    Node dummy{0, head};                 // fake node in front of the real head
    Node* prev = &dummy;
    while (prev->next) {
        if (prev->next->val == x) {
            Node* t = prev->next;
            prev->next = t->next;        // unlink, prev stays where it is
            delete t;
        } else {
            prev = prev->next;           // move on only when nothing was removed
        }
    }
    return dummy.next;                   // the head may have changed
}

খেয়াল করুন দুটি সূক্ষ্ম বিষয়। (১) কিছু মুছলে prev এগোয় না — কারণ নতুন prev->next-ও x হতে পারে (যেমন 7 → 7 → 7). (২) dummy একটি সাধারণ local ভ্যারিয়েবল, heap-এ নয় — তাই একে delete করতে হয় না। এই ফাংশনের loop invariant: “dummy থেকে prev পর্যন্ত অংশে x-এর কোনো node নেই।” প্রতিটি ধাপে এটি টিকে থাকে, আর লুপ শেষে prev শেষ node-এ — তাই পুরো list-এ আর কোনো x নেই। সময় Θ(n), বাড়তি জায়গা O(1).

মূল ধারণা

যখনই দেখবেন linked list-এর কোডে “head হলে এক রকম, অন্যথায় আরেক রকম” লিখছেন — একটি dummy head বসিয়ে দেখুন। নতুন list তৈরি করা (অংশ ৮-এর merge), কিছু মুছে ফেলা, বা শেষ থেকে n-তম node সরানো — সবখানে এই কৌশল কোডকে ছোট আর নির্ভুল করে। ইন্টারভিউতে এটি প্রায় সবচেয়ে বেশি ব্যবহৃত linked list কৌশল।

৫ · Doubly আর circular linked list

Doubly linked list — আগে-পরে দুই দিকেই তীর

Singly list-এর দুটি সীমাবদ্ধতা: পেছনে হাঁটা যায় না, আর একটি node হাতে থাকলেও তাকে মুছতে তার আগের node খুঁজতে হয় (O(n)). সমাধান: প্রতিটি node-এ দ্বিতীয় একটি pointer, prev, যা আগের node দেখায়।

null ← [10] ⇄ [20] ⇄ [30] → null
        head          tail
struct DNode { int val; DNode *prev, *next; };

// unlink node x in O(1): both neighbours are reachable from x itself
void unlink(DNode* x) {
    if (x->prev) x->prev->next = x->next;   // (else x was the head: update head)
    if (x->next) x->next->prev = x->prev;   // (else x was the tail: update tail)
    delete x;
}

এখন node হাতে থাকলে delete O(1), শেষ node মোছাও O(1) (tail-এর prev-ই নতুন tail), আর উল্টো দিকে traversal সম্ভব। দাম: প্রতিটি node-এ আরেকটি pointer (64-bit মেশিনে আরও 8 byte), আর প্রতিটি insert/delete-এ চারটি তীর ঠিক রাখার দায়িত্ব — একটি ভুললেই list এক দিক থেকে ঠিক, আরেক দিক থেকে ভাঙা।

ওপরের কোডে দুটি if লক্ষ করুন — আবার সেই বিশেষ ক্ষেত্র। বাস্তবের লাইব্রেরি এটি এড়ায় একটি sentinel দিয়ে: একটিমাত্র header node, যার next প্রথম node আর prev শেষ node, আর list-টি বৃত্তাকারে বন্ধ। তখন প্রতিটি আসল node-এর দুই পাশেই কেউ না কেউ থাকে, if লাগে না। GCC-র std::list আর Linux kernel-এর list_head দুটোই এই নকশা ব্যবহার করে।

C++-এ তৈরি doubly linked list হলো std::list (আর singly list হলো std::forward_list, C++11 থেকে)। এর বিশেষ গুণ: কোনো উপাদান যোগ বা মুছলে অন্য উপাদানের iterator অচল হয় না, আর splice দিয়ে একটি node বা পুরো আরেকটি list O(1)-এ জুড়ে দেওয়া যায় — কোনো কপি ছাড়াই।

#include <list>
std::list<int> L = {10, 20, 30};
auto it = std::next(L.begin());   // iterator to 20
L.insert(it, 15);                 // 10 15 20 30   O(1)
L.erase(it);                      // 10 15 30      O(1), other iterators stay valid
L.push_front(5); L.push_back(40); // 5 10 15 30 40 O(1) each

Python-এ আলাদা কোনো linked list টাইপ নেই। list আসলে একটি dynamic array (পর্ব ১০)। দুই প্রান্তে দ্রুত যোগ-বিয়োগের জন্য আছে collections.deque, যা CPython-এ ভেতরে ছোট ছোট array-ব্লকের একটি doubly linked list — পর্ব ১৪-এ এটি বিস্তারিত দেখবেন। Java-র LinkedList একটি doubly linked list.

Doubly list-এর সবচেয়ে বিখ্যাত ব্যবহার LRU cache (least recently used — “যে জিনিস সবচেয়ে বেশি সময় ধরে ব্যবহার হয়নি, সেটি আগে বাদ”): একটি hash table প্রতিটি key-র node সরাসরি খুঁজে দেয়, আর doubly list ব্যবহারের ক্রম রাখে। কোনো key ব্যবহার হলে তার node O(1)-এ তুলে list-এর সামনে বসানো হয়, জায়গা ফুরালে পেছনের node O(1)-এ বাদ। Hash table পর্ব ৩২-এ আসবে; তখন এই জোড়টি আবার মনে করবেন।

Circular linked list — শেষ থেকে আবার শুরুতে

শেষ node-এর next যদি null না হয়ে আবার প্রথম node-কে দেখায়, তাহলে পাই circular linked list. এখানে “শেষ” বলে কিছু নেই — একটি pointer চিরকাল ঘুরতে পারে। প্রায়ই head-এর বদলে শুধু tail রাখা হয়, কারণ tail->next-ই head — একটি pointer দিয়ে দুই প্রান্তই হাতের নাগালে।

   ┌──────────────────────┐
   ↓                      │
  [A] → [B] → [C] → [D] ──┘
                     tail

Round-robin scheduling. আপনার কম্পিউটারে একসাথে অনেক প্রোগ্রাম চলছে, কিন্তু CPU core হাতে গোনা। Operating system প্রতিটি প্রোগ্রামকে পালা করে অল্প সময় (time slice) দেয় — A, তারপর B, তারপর C, তারপর আবার A-র পালা। এই পদ্ধতির নাম round-robin scheduling, আর অপেক্ষমাণ প্রোগ্রামগুলোকে circular list-এ রাখা খুব স্বাভাবিক: “পরের জন” মানে শুধু cur = cur->next, শেষে পৌঁছে আলাদা করে শুরুতে ফেরার নিয়ম লাগে না। কোনো প্রোগ্রাম শেষ হলে তার node O(1)-এ বাদ। চায়ের দোকানে কয়েকজন বন্ধু পালা করে বিল দিলে যেমন — “এবার কার পালা” গোনা চক্রাকারেই চলে।

Josephus problem. একটি পুরোনো ধাঁধা, ইতিহাসবিদ Flavius Josephus-এর নামে: n জন মানুষ বৃত্তে দাঁড়িয়ে। ১ থেকে গোনা শুরু; প্রতি k-তম জন বৃত্ত থেকে বেরিয়ে যান, আর গোনা চলে পরের জন থেকে। শেষে কে টিকে থাকবেন? n = 7, k = 3 হলে বেরোনোর ক্রম 3, 6, 2, 7, 5, 1 — টিকে থাকেন ৪ নম্বর। circular list-এ এটি হুবহু অনুকরণ করা যায়: k − 1 ধাপ এগোন, পরের node মুছুন, আবার। মোট সময় Θ(n·k).

কিন্তু এখানে অ্যালগরিদমিক চিন্তা আরও ভালো উত্তর দেয়। 0 থেকে নম্বর দিলে, n জনের বৃত্তে বিজয়ীর অবস্থান J(n) মেনে চলে J(1) = 0 আর J(n) = (J(n − 1) + k) mod n — কারণ প্রথম জন বেরোনোর পর বাকি n − 1 জনের সমস্যাটি একই, শুধু নম্বরগুলো k ঘর সরে গেছে। এতে সময় Θ(n), কোনো list ছাড়াই। n = 7, k = 3-এ: J = 0, 1, 1, 0, 3, 0, 3 → 0-based 3 মানে ৪ নম্বর জন — অনুকরণের সাথে মিলে গেল।

def josephus(n, k):          # 1-based survivor, Theta(n) time, O(1) space
    j = 0                    # J(1) = 0
    for m in range(2, n + 1):
        j = (j + k) % m      # J(m) = (J(m-1) + k) mod m
    return j + 1

print(josephus(7, 3))        # 4

৬ · Array বনাম linked list — কাগজে এক, বাস্তবে আরেক

এবার দুটিকে মুখোমুখি দাঁড় করাই। নিচের সারণিতে n = উপাদানের সংখ্যা; “জায়গা জানা” মানে সংশ্লিষ্ট node বা index আগে থেকেই হাতে।

কাজDynamic array (vector)Singly listDoubly list
k-তম উপাদান পড়াO(1)Θ(k)Θ(min(k, n − k)), দুই প্রান্ত থেকে
শুরুতে যোগ / মোছাΘ(n) — সবাইকে সরাতে হয়O(1)O(1)
শেষে যোগamortized O(1)O(1) (tail থাকলে)O(1)
শেষেরটি মোছাO(1)Θ(n) — আগেরটি খুঁজতেO(1)
মাঝে যোগ / মোছা (জায়গা জানা)Θ(n) সরানোO(1) পরে যোগ; মোছায় আগের node লাগেO(1)
মান খোঁজাΘ(n); sorted হলে O(log n)Θ(n), binary search অর্থহীনΘ(n)
প্রতি উপাদানে বাড়তি মেমরিপ্রায় শূন্য (+ খালি capacity)১টি pointer + allocator-এর খরচ২টি pointer + allocator-এর খরচ
Cache localityচমৎকার — পাশাপাশিদুর্বল — ছড়ানোদুর্বল — ছড়ানো

সারণি দেখে মনে হতে পারে, যেখানে অনেক insert/delete, সেখানে linked list-ই সেরা। বাস্তবে প্রায়ই উল্টো। কারণ দুটি।

(১) Cache locality. আধুনিক CPU মেমরি থেকে একটি একটি byte আনে না — একসাথে একটি পুরো “cache line” (সাধারণত 64 byte) আনে, আর প্যাটার্ন বুঝে পরের অংশও আগেভাগে আনতে থাকে (prefetch). vector-এ পাশাপাশি 16টি int এক cache line-এ চলে আসে, তাই traversal উড়ে চলে। linked list-এর প্রতিটি node মেমরির অন্য কোথাও; প্রতিটি cur = cur->next হতে পারে একটি cache miss, যার খরচ cache থেকে পড়ার চেয়ে প্রায় শত গুণ বেশি হতে পারে। আর পরের node-এর ঠিকানা আগের node পড়ার আগে জানাই যায় না, তাই CPU আগেভাগে আনতেও পারে না। দুটিই O(n), কিন্তু ধ্রুবকে বিশাল পার্থক্য।

(২) Memory overhead. 64-bit মেশিনে 4 byte-এর একটি int রাখতে singly node-এ লাগে 4 + 4 (padding) + 8 (pointer) = 16 byte, আর glibc-এর মতো সাধারণ allocator প্রতিটি ছোট বরাদ্দে নিজের হিসাবের জন্যও জায়গা নেয় — বাস্তবে প্রায়ই প্রতি node-এ 32 byte. অর্থাৎ ডেটার ৮ গুণ মেমরি! আর প্রতিটি new নিজেও একটি তুলনামূলক ধীর কাজ।

লাইব্রেরির তাকে পরপর সাজানো ১০০টি বই (array) বনাম শহরজুড়ে ১০০টি বাসায় একটি করে বই, প্রতিটিতে পরের বাসার ঠিকানা লেখা (linked list). মাঝে একটি নতুন বই ঢোকাতে তাকে সবাইকে সরাতে হয় — ঝামেলা। কিন্তু ১০০টি বই পড়ে শেষ করতে? তাকের সামনে দাঁড়িয়ে হাত বাড়ালেই হয়; অন্যটিতে ঢাকার জ্যামে ১০০ বার রিকশা নিতে হবে। ঝামেলার “সরানো” কাজটিও তাকের পাশে খুব দ্রুত — একসাথে একটা গোছা ঠেলে দিলেই হয়।

এজন্য C++-এর স্রষ্টা Bjarne Stroustrup-সহ অনেক বিশেষজ্ঞ পরামর্শ দেন: ডিফল্ট হিসেবে vector নিন; linked list নিন তখনই, যখন মাপজোক করে প্রমাণ পেয়েছেন। Stroustrup একটি বক্তৃতায় দেখিয়েছিলেন, sorted ক্রম বজায় রেখে এলোমেলো জায়গায় উপাদান ঢোকানো আর মোছার পরীক্ষায় vector, list-কে বড় ব্যবধানে হারায় — কারণ “জায়গা খুঁজতে” দুটিকেই হাঁটতে হয়, আর হাঁটায় vector অনেক দ্রুত।

তাহলে linked list কখন সত্যিই সঠিক পছন্দ?

  • উপাদানের ঠিকানা বা iterator বদলানো চলবে না — অন্য অনেক জায়গা থেকে node-কে সরাসরি দেখানো হচ্ছে (LRU cache, OS-এর process তালিকা)।
  • node হাতে রেখে O(1)-এ মাঝখান থেকে মোছা বা পুরো list জোড়া (splice) দরকার, আর হাঁটার দরকার কম।
  • প্রতিটি উপাদান বিশাল, সরানো খুব খরুচে; অথবা real-time সিস্টেমে vector-এর হঠাৎ resize-এর বিরতি সহ্য করা যায় না।
  • গ্রাফের adjacency list, hash table-এর chaining, memory allocator-এর free list — যেখানে অনেকগুলো ছোট তালিকা দরকার (পর্ব ৩২, ৩৯)।

৭ · মেমরির দায়িত্ব — C++-এ new/delete, Python-এ garbage collector

Linked list-এর প্রতিটি node আলাদা করে heap থেকে নেওয়া। কে এই মেমরি ফেরত দেবে? এখানে C++ আর Python দুটি সম্পূর্ণ আলাদা দর্শন মেনে চলে।

C++ — দায়িত্ব আপনার

new দিয়ে নেওয়া প্রতিটি node ঠিক একবার delete করতে হবে। ভুললে memory leak; দুবার করলে বা মুছে ফেলা node আবার ব্যবহার করলে (dangling pointer) undefined behavior.

দ্রুত, কিন্তু ভুলের সুযোগ বেশি

Python — দায়িত্ব runtime-এর

কোনো বস্তুকে আর কেউ দেখাচ্ছে না, এটা টের পেলেই garbage collection তাকে মুছে দেয়। CPython মূলত reference counting ব্যবহার করে, সাথে চক্র (cycle) ধরার জন্য আলাদা একটি collector.

নিরাপদ, কিন্তু বাড়তি খরচ

C++-এ পুরো list মুছে ফেলার সঠিক উপায় একটি লুপ — প্রতিটি node মোছার আগে তার next ধরে রাখতে হবে, কারণ মোছার পরে সেই node-এর ভেতরে পড়া নিষেধ:

void destroy(Node* head) {
    while (head) {
        Node* nxt = head->next;   // save first: after delete, head->next is gone
        delete head;
        head = nxt;
    }
}
// WRONG: for (Node* c = head; c; c = c->next) delete c;   // reads c->next after delete

আধুনিক C++ এই দায়িত্ব অনেকটা কমায় smart pointer দিয়ে। std::unique_ptr<Node> next; লিখলে প্রতিটি node তার পরের node-এর “মালিক”, আর node ধ্বংস হলে সে নিজে থেকেই পরেরটিকে মুছে দেয় — delete লেখার দরকার নেই, leak অসম্ভব। তবে একটি ফাঁদ আছে: এই মোছা রিকার্সিভভাবে ঘটে (node 1-এর destructor ডাকে node 2-এরটি, সেটি node 3-এরটি…)। দশ লাখ node-এর list-এ সেটি দশ লাখ গভীর রিকার্শন — stack overflow (পর্ব ০৭)। তাই unique_ptr-ভিত্তিক list-এও destructor-এ একটি লুপ লিখে node-গুলো একে একে ছেড়ে দেওয়া হয়।

Python-এ head = None লিখলেই প্রথম node-এর reference count শূন্য হয়, সে মুছে যায়, তার ফলে দ্বিতীয়টির count শূন্য… পুরো শিকল ভেঙে পড়ে। কিন্তু list-এ যদি cycle থাকে (শেষ node আবার আগের কোনো node দেখায়), তাহলে প্রতিটি node-কে অন্তত একজন দেখাচ্ছে — reference count কখনো শূন্য হয় না। এই ক্ষেত্রেই CPython-এর cycle collector (gc মডিউল) কাজে আসে: সে পর্যায়ক্রমে খোঁজে, কোন বস্তুর দল বাইরে থেকে আর পৌঁছানো যায় না, আর পুরো দলটিকে মুছে দেয়।

Cycle থাকলে C++-এর destroy লুপ কখনো null পায় না। ওপরের destroy লুপ null-এর অপেক্ষায় থাকে; cycle-এ null আসে না, তাই লুপ ঘুরে এসে ইতিমধ্যে মুছে ফেলা node-এর ভেতরে পড়ে আর সেটি আবার মুছতে যায় — undefined behavior, সাধারণত crash. অংশ ৯-এর প্রোগ্রামে ঠিক এজন্যই সব node একটি vector<Node*>-এ জমা রেখে শেষে একবার করে মোছা হয়েছে। বড় প্রোগ্রামে এই কাজ সাধারণত একটি memory pool বা arena করে: সব node এক জায়গা থেকে নেওয়া, শেষে একসাথে ফেরত। কনটেস্টে অনেকে আরও সহজ পথ নেন — node-গুলো একটি বড় array-তে রেখে pointer-এর বদলে index ব্যবহার করেন (“array-ভিত্তিক linked list”), যা দ্রুত আর leak-এর ভয়ও নেই।

৮ · ক্লাসিক সমস্যা — উল্টানো, মাঝখান, merge আর শেষ থেকে n-তম

List উল্টানো — তিনটি pointer

“একটি singly linked list উল্টে দিন” — সম্ভবত সবচেয়ে বিখ্যাত linked list ইন্টারভিউ প্রশ্ন। নতুন list না বানিয়ে, O(1) বাড়তি জায়গায় (in-place) করতে হবে। মূল চিন্তা: প্রতিটি node-এর তীর ঘুরিয়ে আগের node-এর দিকে করে দিন। সমস্যা একটাই — তীর ঘোরালেই সামনের অংশের ঠিকানা হারিয়ে যায়। তাই তিনটি pointer: prev (উল্টানো অংশের মাথা), cur (এখন যে node), nxt (ঘোরানোর আগে বাঁচিয়ে রাখা বাকি অংশ)।

1Node* reverse(Node* head) {2    Node* prev = nullptr;3    Node* cur = head;4    while (cur != nullptr) {5        Node* nxt = cur->next;   // 1) save the rest6        cur->next = prev;        // 2) flip the arrow7        prev = cur;              // 3) move prev8        cur = nxt;               // 4) move cur9    }10    return prev;                 // prev is the new head11}
Pointer-গুলো
prev
cur
nxt
শেষ হওয়া ঘূর্ণন
বাক্স আর তীরবাঁ দিক = উল্টানো অংশ

                    
  1. শুরু। prev = null (উল্টানো অংশ এখনো খালি), cur = head = [1]. list: [1] → [2] → [3] → null.
  2. ঘূর্ণন ১, ধাপ ১: বাকিটা বাঁচাও। nxt = cur->next, অর্থাৎ nxt = [2]. এই লাইন না থাকলে পরের ধাপে [2]-এর ঠিকানা চিরতরে হারাত।
  3. ধাপ ২: তীর ঘোরাও। cur->next = prev — [1] এখন null দেখায়। list দুই টুকরো: উল্টানো অংশ null ← [1], আর বাকি অংশ [2] → [3] → null, যাকে শুধু nxt ধরে রেখেছে।
  4. ধাপ ৩–৪: দুজনেই এক ঘর এগোও। prev = [1], cur = [2]. ঘূর্ণন ১ শেষ। Invariant: prev থেকে শুরু হওয়া অংশ মূল list-এর প্রথম অংশের উল্টো রূপ, আর cur থেকে শুরু হওয়া অংশ এখনো ছোঁয়া হয়নি।
  5. ঘূর্ণন ২. একই চার লাইন: nxt = [3], [2]-এর তীর ঘুরে [1]-এ, prev = [2], cur = [3]. উল্টানো অংশ: null ← [1] ← [2].
  6. ঘূর্ণন ৩. nxt = null, [3]-এর তীর ঘুরে [2]-এ, prev = [3], cur = null. লুপের শর্ত মিথ্যা — সব node উল্টানো হয়ে গেছে।
  7. নতুন head. ফাংশন prev = [3] ফেরত দেয়। list: [3] → [2] → [1] → null. প্রতিটি node-এ ঠিক একবার O(1) কাজ: সময় Θ(n), বাড়তি জায়গা O(1) — শুধু তিনটি pointer.

শুদ্ধতার যুক্তি। লুপের প্রতিটি ঘূর্ণনের শুরুতে সত্য থাকে: “মূল list-এর প্রথম i টি node উল্টো ক্রমে prev থেকে শুরু হয়ে null-এ শেষ, আর বাকি node-গুলো মূল ক্রমে cur থেকে শুরু।” শুরুতে i = 0 (prev = null, cur = head) — সত্য। এক ঘূর্ণনে cur-এর node উল্টানো অংশের সামনে যায় — i + 1-এর জন্যও সত্য। লুপ শেষে cur = null, অর্থাৎ বাকি অংশ খালি, তাই prev থেকে পুরো list উল্টো ক্রমে। এটিই পর্ব ০৩-এর loop invariant-এর পদ্ধতি।

রিকার্সিভ সংস্করণ। পর্ব ০৭-এর leap of faith দিয়ে ভাবুন: “head-এর পরের অংশটি উল্টে দেবে রিকার্সিভ কল; আমার কাজ শুধু head-কে তার শেষে জোড়া।” উল্টানোর পর পুরোনো head->next node-টি হয়ে গেছে উল্টানো অংশের শেষ node, তাই তার next-এ head বসালেই হলো:

Node* reverseRec(Node* head) {
    if (!head || !head->next) return head;    // 0 or 1 node: already reversed
    Node* newHead = reverseRec(head->next);   // leap of faith: the rest is reversed
    head->next->next = head;                  // old second node now points back to head
    head->next = nullptr;                     // head becomes the last node
    return newHead;
}

সুন্দর, কিন্তু বাড়তি জায়গা O(1) নয় — recursion depth n, তাই call stack-এ Θ(n). লাখখানেক node-এর list-এ C++-এ stack overflow-এর ঝুঁকি, আর Python-এ ১০০০-এর বেশি node হলেই RecursionError. ইন্টারভিউতে দুটিই জানতে চাওয়া হয়, আর সাথে এই জায়গার পার্থক্যও।

মাঝখান খোঁজা — slow আর fast pointer

দৈর্ঘ্য না জেনে এক পাসে মাঝের node বের করতে চাই। কৌশল: দুটি pointer একসাথে head থেকে ছাড়ুন — slow প্রতি ধাপে এক node, fast দুই node. fast যখন শেষে পৌঁছায়, slow তখন ঠিক অর্ধেক পথে। একে বলে fast-slow pointer কৌশল (two pointer-এর একটি রূপ, পর্ব ১৬)।

Node* middle(Node* head) {
    Node *slow = head, *fast = head;
    while (fast && fast->next) {       // fast can take two more steps
        slow = slow->next;
        fast = fast->next->next;
    }
    return slow;   // n odd: the middle; n even: the second of the two middles
}

1 → 2 → 3 → 4 → 5-এ slow যায় 1, 2, 3 আর fast 1, 3, 5 — থামে, slow = 3. 1 → … → 6-এ slow 1, 2, 3, 4 আর fast 1, 3, 5, null — slow = 4, দুটি মাঝের দ্বিতীয়টি। প্রথমটি চাইলে শর্ত লিখুন fast->next && fast->next->next. সময় Θ(n), জায়গা O(1). merge sort-কে linked list-এ চালাতে (list দুই ভাগ করতে) ঠিক এই ফাংশনই লাগে।

দুটি sorted list জোড়া (merge)

পরীক্ষার খাতা দুই শিক্ষক আলাদা করে রোল নম্বর অনুযায়ী সাজিয়েছেন; এখন দুটি গাদা মিলিয়ে একটি সাজানো গাদা চাই। দুই গাদার ওপরের খাতা তুলনা করুন, ছোটটি নতুন গাদায় দিন, আবার — এভাবে। linked list-এ কোনো নতুন node বানাতে হয় না: শুধু তীর জুড়ে জুড়ে নতুন ক্রম তৈরি হয়। dummy head আবার কাজে আসে — শুরুতে “নতুন list-এর প্রথম node কোনটি” নিয়ে আলাদা শাখা লাগে না।

Node* merge(Node* a, Node* b) {              // a and b are sorted
    Node dummy{0, nullptr};
    Node* tail = &dummy;
    while (a && b) {
        Node*& smaller = (a->val <= b->val) ? a : b;   // reference to a or b
        tail->next = smaller;                // link it
        tail = smaller;
        smaller = smaller->next;             // advance that list
    }
    tail->next = a ? a : b;                  // attach whatever is left
    return dummy.next;
}

প্রতিটি ধাপে একটি node নতুন list-এ যায়, তাই সময় Θ(n + m), বাড়তি জায়গা O(1). <= ব্যবহারে সমান মানে a-এর node আগে আসে — ফলে ক্রম stable থাকে (পর্ব ২৫)। এটিই পর্ব ২১-এর merge sort-এর merge ধাপ; linked list-এ merge sort O(n log n) সময়ে চলে, আর array-র মতো বাড়তি O(n) জায়গাও লাগে না — তাই linked list sort করার জন্য এটিই পছন্দের অ্যালগরিদম।

শেষ থেকে n-তম node মোছা — এক পাসে

দৈর্ঘ্য জানলে সহজ: L − n + 1 তম node মুছুন — কিন্তু তাতে দুটি পাস লাগে। এক পাসে করতে আবার দুটি pointer: fast-কে আগে n ঘর এগিয়ে দিন, তারপর দুজনকে একসাথে চালান। fast যখন শেষ node-এ, slow তখন ঠিক মুছতে-চাওয়া node-এর আগের node-এ — কারণ দুজনের ব্যবধান সবসময় n. dummy থেকে শুরু করলে “head নিজেই মুছতে হবে” (n = L) ক্ষেত্রটিও আলাদা করে ভাবতে হয় না।

Node* removeNthFromEnd(Node* head, int n) {   // assumes 1 <= n <= length
    Node dummy{0, head};
    Node *fast = &dummy, *slow = &dummy;
    for (int i = 0; i < n; ++i) fast = fast->next;   // open a gap of n
    while (fast->next) { fast = fast->next; slow = slow->next; }
    Node* t = slow->next;                     // the n-th node from the end
    slow->next = t->next;
    delete t;
    return dummy.next;
}

যাচাই: 1 → 2 → 3 → 4 → 5, n = 2. ফাঁক তৈরির পর fast = [2], slow = dummy. একসাথে চলে fast [3], [4], [5]; slow [1], [2], [3]. fast-এর next null — থামুন। slow = [3], মুছবে [4] — শেষ থেকে দ্বিতীয়। ✔ n = 5 হলে fast = [5], লুপ একবারও চলে না, slow = dummy, মুছবে [1] = head — dummy ছাড়া এখানেই ভুল হতো। সময় Θ(L), জায়গা O(1).

৯ · Floyd-এর cycle detection — কচ্ছপ আর খরগোশ

ধরুন কোনো বাগের কারণে একটি list-এর শেষ node null-এর বদলে আগের কোনো node দেখাচ্ছে। এখন traversal কখনো থামবে না। প্রশ্ন: list-এ cycle আছে কি না, আর থাকলে কোন node থেকে শুরু?

সহজ উপায়: দেখা প্রতিটি node-এর ঠিকানা একটি hash set-এ রাখুন; কোনো ঠিকানা দ্বিতীয়বার এলে সেটিই cycle-এর শুরু। সময় O(n), কিন্তু জায়গাও O(n). Robert W. Floyd-এর নামে পরিচিত একটি অসাধারণ কৌশল একই কাজ করে মাত্র দুটি pointer দিয়ে — Floyd's cycle detection, ডাকনাম “কচ্ছপ আর খরগোশ” (tortoise and hare). slow (কচ্ছপ) এক ধাপে চলে, fast (খরগোশ) দুই ধাপে। cycle না থাকলে fast null-এ পৌঁছায়। cycle থাকলে দুজনেই একসময় চক্রে ঢোকে, আর দৌড়ের মাঠে দ্রুততর দৌড়বিদ যেমন একসময় ধীরজনকে “এক চক্কর পেছন থেকে” ধরে ফেলেন, fast-ও slow-কে ধরে ফেলে।

নিচের list: 1 → 2 → 3 → 4 → 5 → 6, আর 6-এর next আবার 3. cycle-এর আগের লেজের দৈর্ঘ্য μ = 2 (node 1, 2), cycle-এর দৈর্ঘ্য λ = 4 (node 3, 4, 5, 6).

1Node* cycleStart(Node* head) {2    Node *slow = head, *fast = head;3    while (fast && fast->next) {4        slow = slow->next;          // 1 step5        fast = fast->next->next;    // 2 steps6        if (slow == fast) {         // they met inside the cycle7            slow = head;            // phase 2: slow back to head8            while (slow != fast) { slow = slow->next; fast = fast->next; }9            return slow;            // the first node of the cycle10        }11    }12    return nullptr;                 // fast fell off the end: no cycle13}
অবস্থা
পর্যায়
slow (s)
fast (f)
চক্রে f, s-এর কত ঘর পেছনে
List (6 → 3 cycle)s = slow, f = fast

                    
  1. শুরু। slow আর fast দুজনেই head [1]-এ। পর্যায় ১-এর লক্ষ্য: cycle আছে কি না জানা।
  2. ধাপ ১. slow এক ঘর → [2]; fast দুই ঘর → [3]. fast ইতিমধ্যে চক্রে ঢুকে গেছে, slow এখনো লেজে।
  3. ধাপ ২. slow → [3] (চক্রে ঢুকল); fast → [5]. দুজনেই এখন চক্রে। চক্রের দিক ধরে fast থেকে slow পর্যন্ত দূরত্ব: [5] → [6] → [3] = ২ ঘর। অর্থাৎ fast, slow-এর ২ ঘর পেছনে।
  4. ধাপ ৩. slow → [4]; fast → [6] → [3]. প্রতি ধাপে fast দুই ঘর আর slow এক ঘর এগোয়, তাই ব্যবধান ঠিক ১ করে কমে: এখন fast মাত্র ১ ঘর পেছনে ([3] → [4]).
  5. ধাপ ৪ — মিলন। slow → [5]; fast → [4] → [5]. slow == fast! cycle নিশ্চিত। ব্যবধান প্রতিবার ঠিক ১ কমে বলে fast কখনো slow-কে “লাফিয়ে পেরিয়ে” যেতে পারে না — মিলন অবধারিত। মিলন হলো t = 4 ধাপে, যা λ = 4-এর গুণিতক।
  6. পর্যায় ২ শুরু। slow-কে আবার head [1]-এ পাঠানো হলো; fast থাকল মিলনস্থল [5]-এ। এবার দুজনেই এক ঘর করে চলবে।
  7. পর্যায় ২, ধাপ ১. slow → [2], fast → [6]. এখনো আলাদা।
  8. পর্যায় ২, ধাপ ২ — cycle-এর শুরু। slow → [3], fast → [6] → [3]. দুজনের দেখা [3]-এ — ঠিক cycle-এর প্রথম node. লাগল μ = 2 ধাপ। কেন এটি সবসময় কাজ করে, নিচের প্রমাণে দেখুন।

কেন দেখা হয়, আর কেন শুরুতেই দেখা হয় — প্রমাণের রূপরেখা

ধরি লেজের দৈর্ঘ্য μ (head থেকে cycle-এর প্রথম node পর্যন্ত ধাপ) আর cycle-এর দৈর্ঘ্য λ. t ধাপ পরে slow আছে head থেকে t ধাপ দূরে, fast আছে 2t ধাপ দূরে (cycle-এর ভেতরে ঘুরে ঘুরে)।

  1. দেখা হবেই। t ≥ μ হলে দুজনেই চক্রে। চক্রের ভেতরে দুটি অবস্থান এক হয় ঠিক তখন, যখন তাদের দূরত্বের পার্থক্য λ-এর গুণিতক: 2t − t = t ≡ 0 (mod λ). অর্থাৎ যে প্রথম t ≥ μ, যা λ দিয়ে বিভাজ্য, সেখানেই দেখা। এমন t সবসময় আছে, আর t ≤ μ + λ − 1 ≤ n. অন্যভাবে: চক্রে ঢোকার পর ব্যবধান প্রতি ধাপে ১ কমে, তাই সর্বোচ্চ λ − 1 ধাপে শূন্য হয়।
  2. শুরুটা কেন পাওয়া যায়। মিলনের মুহূর্তে t = mλ (কোনো পূর্ণসংখ্যা m ≥ 1-এর জন্য)। এবার slow-কে head-এ ফেরত পাঠিয়ে দুজনকে এক ঘর করে চালাই। μ ধাপ পরে slow ঠিক cycle-এর প্রথম node-এ। fast তখন head থেকে t + μ = mλ + μ ধাপ দূরের অবস্থানে — অর্থাৎ cycle-এর শুরু থেকে আরও mλ ধাপ, মানে m বার পুরো চক্কর দিয়ে আবার শুরুর node-এই। দুজন প্রথমবার সেখানেই মেলে, কারণ μ-এর আগে slow লেজে আর fast চক্রে — আলাদা জায়গায়।
  3. খরচ। পর্যায় ১-এ সর্বোচ্চ μ + λ ধাপ, পর্যায় ২-এ μ ধাপ — মোট O(μ + λ) = O(n) সময়। জায়গা শুধু দুটি pointer: O(1).

ওপরের উদাহরণে মিলিয়ে দেখুন: μ = 2, λ = 4. প্রথম t ≥ 2 যা 4 দিয়ে বিভাজ্য: t = 4 — ঠিক ধাপ ৪-এ মিলন। তারপর পর্যায় ২-এ μ = 2 ধাপে [3]. এবার পুরো প্রোগ্রাম চালিয়ে দেখুন: list তৈরি, ছাপা, উল্টানো, আর দুটি list-এ cycle খোঁজা:

#include <iostream>
#include <vector>
using namespace std;

struct Node { int val; Node* next = nullptr; };

Node* build(const vector<int>& xs, vector<Node*>& pool) {  // pool owns every node
    Node dummy{0}, *tail = &dummy;                         // dummy head: no special case
    for (int x : xs) { pool.push_back(new Node{x}); tail = tail->next = pool.back(); }
    return dummy.next;
}
void print(const Node* h) {
    for (; h; h = h->next) cout << h->val << " -> ";
    cout << "null\n";
}
Node* reverse(Node* cur) {                                 // O(n) time, O(1) extra space
    Node* prev = nullptr;
    while (cur) { Node* nxt = cur->next; cur->next = prev; prev = cur; cur = nxt; }
    return prev;
}
Node* cycleStart(Node* h) {                                // Floyd; nullptr = no cycle
    Node *slow = h, *fast = h;
    while (fast && fast->next) {
        slow = slow->next; fast = fast->next->next;
        if (slow == fast) {                                // met: restart slow from head
            for (slow = h; slow != fast; slow = slow->next) fast = fast->next;
            return slow;
        }
    }
    return nullptr;
}
int main() {
    vector<Node*> pool;
    Node* h = build({1, 2, 3, 4, 5}, pool);
    print(h);
    h = reverse(h);
    print(h);
    cout << "cycle? " << (cycleStart(h) ? "yes" : "no") << '\n';
    Node* c = build({1, 2, 3, 4, 5, 6}, pool);
    pool.back()->next = c->next->next;                     // 6 -> 3 makes a cycle
    cout << "cycle starts at " << cycleStart(c)->val << '\n';
    for (Node* p : pool) delete p;                         // free each node exactly once
}

আউটপুট:

1 -> 2 -> 3 -> 4 -> 5 -> null
5 -> 4 -> 3 -> 2 -> 1 -> null
cycle? no
cycle starts at 3

build dummy head-এর কৌশলে node-গুলো শেষে জোড়ে, তাই প্রথম node-এর জন্য কোনো আলাদা if নেই; tail = tail->next = pool.back() একসাথে দুটি কাজ করে — শেষ node-এর তীর নতুন node-এ, আর tail এক ঘর এগোয়। উল্টানো list-এ cycle নেই, তাই fast null-এ পৌঁছে nullptr ফেরত — “no”. দ্বিতীয় list-এ pool.back() হলো সদ্য তৈরি node 6, আর c->next->next হলো node 3; তাদের জুড়ে স্টেপারের হুবহু cycle বানানো হয়েছে, আর Floyd ঠিক 3 ফেরত দেয়। শেষ লাইনটি খেয়াল করুন: cycle-যুক্ত list-কে destroy-এর মতো লুপে মোছা যেত না (অংশ ৭), তাই সব node pool-এ রেখে প্রতিটি ঠিক একবার delete করা হয়েছে।

class Node:
    __slots__ = ("val", "next")
    def __init__(self, val, nxt=None):
        self.val, self.next = val, nxt

def build(xs):
    dummy = tail = Node(0)                 # dummy head: no special case
    for x in xs:
        tail.next = tail = Node(x)         # link the new node, then move tail
    return dummy.next

def show(h):
    vals = []
    while h:
        vals.append(str(h.val)); h = h.next
    print(" -> ".join(vals + ["null"]))

def reverse(cur):                          # O(n) time, O(1) extra space
    prev = None
    while cur:
        nxt = cur.next
        cur.next = prev
        prev, cur = cur, nxt
    return prev

def cycle_start(h):                        # Floyd; None = no cycle
    slow = fast = h
    while fast and fast.next:
        slow, fast = slow.next, fast.next.next
        if slow is fast:                   # met: restart slow from head
            slow = h
            while slow is not fast:
                slow, fast = slow.next, fast.next
            return slow
    return None

h = build([1, 2, 3, 4, 5])
show(h)
h = reverse(h)
show(h)
print("cycle?", "yes" if cycle_start(h) else "no")
c = build([1, 2, 3, 4, 5, 6])
last = c.next.next.next.next.next          # node 6
last.next = c.next.next                    # 6 -> 3 makes a cycle
print("cycle starts at", cycle_start(c).val)

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

1 -> 2 -> 3 -> 4 -> 5 -> null
5 -> 4 -> 3 -> 2 -> 1 -> null
cycle? no
cycle starts at 3

Python-এ pointer-এর বদলে reference, আর nullptr-এর বদলে None; বাকি যুক্তি এক। দুটি বিষয় লক্ষ করুন। (১) node তুলনায় is ব্যবহার করা হয়েছে, == নয় — আমরা জানতে চাই “একই node কি না”, “মান সমান কি না” নয় (দুটি আলাদা node-এ একই মান 7 থাকতেই পারে)। (২) __slots__ প্রতিটি Node বস্তুর মেমরি কমায়, কারণ তখন আর প্রতিটি বস্তুর জন্য আলাদা dictionary তৈরি হয় না। কোথাও delete নেই: প্রোগ্রাম শেষে cycle-যুক্ত list-টিও garbage collector-এর দায়িত্ব।

আরও গভীরে: μ আর λ দুটোই বের করা যায় O(1) জায়গায় — মিলনের পর fast-কে থামিয়ে slow-কে এক চক্কর ঘুরিয়ে গুনলেই λ. ১৯৮০ সালে Richard Brent একটি বিকল্প দেন (Brent's algorithm): খরগোশ ধাপে ধাপে লাফের দৈর্ঘ্য দ্বিগুণ করে, আর গড়ে Floyd-এর চেয়ে কম ধাপে cycle ধরে। একই ধারণা linked list ছাড়িয়ে যেকোনো ফাংশন x → f(x)-এর পুনরাবৃত্তিতে চলে — Pollard-এর rho পদ্ধতি (১৯৭৫) একে কাজে লাগিয়ে বড় সংখ্যার উৎপাদক খোঁজে, আর pseudo-random number generator-এর চক্রের দৈর্ঘ্য মাপতেও এটি ব্যবহৃত হয়।

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

নিচের ছোট টুলটিতে “চালিয়ে দেখুন” চাপুন। একটি সংখ্যা লিখে বোতাম চাপলে list-এ সেটি শুরুতে বা শেষে যোগ হবে, মুছে যাবে, পুরো list উল্টে যাবে, বা slow/fast দিয়ে মাঝের node লাল হয়ে জ্বলে উঠবে। শেষে যোগ করার সময় “steps” সংখ্যাটি খেয়াল করুন — list যত লম্বা, তত বেশি।

// A small singly linked list, drawn as boxes and arrows
class LNode { constructor(val, next = null) { this.val = val; this.next = next; } }
let lhead = null, mark = null;
const $ = id => document.getElementById(id);
const toText = () => { const a = []; for (let c = lhead; c; c = c.next) a.push(c.val); return a.concat("null").join(" -> "); };
function show(msg) {
  let h = "head →";
  for (let c = lhead; c; c = c.next)
    h += ` <span class="nd${c === mark ? " hl" : ""}"><b>${c.val}</b><i>•</i></span> →`;
  $("vw").innerHTML = h + " null";
  $("msg").textContent = msg;
  console.log(msg.padEnd(22) + toText());
}
function pushFront(v) { lhead = new LNode(v, lhead); mark = lhead; show(`pushFront(${v}): 1 step`); }
function pushBack(v) {
  const dummy = new LNode(0, lhead); let t = dummy, steps = 0;
  while (t.next) { t = t.next; steps++; }
  t.next = mark = new LNode(v); lhead = dummy.next; show(`pushBack(${v}): ${steps} steps`);
}
function removeVal(v) {
  const dummy = new LNode(0, lhead); let p = dummy;
  while (p.next && p.next.val !== v) p = p.next;
  const ok = p.next !== null; if (ok) p.next = p.next.next;
  lhead = dummy.next; mark = null; show(`remove(${v}): ${ok ? "done" : "not found"}`);
}
function reverseAll() {
  let prev = null, cur = lhead;
  while (cur) { const nxt = cur.next; cur.next = prev; prev = cur; cur = nxt; }
  lhead = prev; mark = null; show("reverse():");
}
function middle() {
  let slow = lhead, fast = lhead;
  while (fast && fast.next) { slow = slow.next; fast = fast.next.next; }
  mark = slow; show(`middle(): ${slow ? slow.val : "none"}`);
}
const num = () => Number($("val").value);
$("bf").onclick = () => pushFront(num()); $("bb").onclick = () => pushBack(num());
$("bd").onclick = () => removeVal(num()); $("br").onclick = reverseAll; $("bm").onclick = middle;
for (const v of [30, 20, 10]) pushFront(v);
pushBack(40); pushBack(50); middle(); removeVal(30); reverseAll();

শুরুতেই কোড নিজে কয়েকটি কাজ করে, আর নিচের আউটপুটে প্রতিটির পরের list দেখায়:

pushFront(30): 1 step 30 -> null
pushFront(20): 1 step 20 -> 30 -> null
pushFront(10): 1 step 10 -> 20 -> 30 -> null
pushBack(40): 3 steps 10 -> 20 -> 30 -> 40 -> null
pushBack(50): 4 steps 10 -> 20 -> 30 -> 40 -> 50 -> null
middle(): 30          10 -> 20 -> 30 -> 40 -> 50 -> null
remove(30): done      10 -> 20 -> 40 -> 50 -> null
reverse():            50 -> 40 -> 20 -> 10 -> null

pushFront সবসময় ১ ধাপ — O(1). pushBack-এ ধাপ list-এর দৈর্ঘ্যের সমান (৩টি node থাকলে ৩, ৪টি থাকলে ৪), কারণ এখানে tail pointer নেই — O(n). removeVal আর pushBack দুটোতেই dummy node আছে, তাই খালি list বা head মোছায় আলাদা শাখা লাগেনি। বাক্সের ডান পাশের নীল “•” অংশটি হলো next-এর ঘর। টুলে একই মান দুবার যোগ করে “মুছুন” চাপুন — শুধু প্রথমটি মোছে; সবগুলো মুছতে অংশ ৪-এর removeAll-এর মতো লুপ লাগবে।

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

  • Singly linked list-এ শুরুতে insert আর k-তম উপাদান পড়ার complexity? — insert O(1); k-তম পড়া Θ(k), worst case Θ(n).
  • শুধু একটি node-এর pointer দেওয়া আছে (head নয়) — singly list থেকে সেটি মুছবেন কীভাবে? — পরের node-এর মান কপি করে পরের node-টি মুছুন; শেষ node হলে এই কৌশল চলে না।
  • List-এ cycle আছে কি না O(1) জায়গায়? — Floyd: slow এক ধাপ, fast দুই ধাপ; মিললে cycle, fast null-এ পৌঁছালে নেই। সময় O(n).
  • Linked list উল্টানোর iterative আর recursive সংস্করণের জায়গার পার্থক্য? — iterative O(1), recursive Θ(n) (call stack).
  • Array-র বদলে linked list কখন নেবেন? — iterator/ঠিকানা স্থির রাখতে হলে, node হাতে রেখে O(1)-এ মাঝে insert/delete বা splice দরকার হলে; শুধু traversal বা index দরকার হলে array.

১০ · AI-এর যুগে linked list — তীর এঁকে যাচাই করুন

Linked list-এর কোড ছোট, তাই AI মুহূর্তে লিখে দেয় — আর প্রায়ই সুন্দর উদাহরণে চলেও। কিন্তু linked list-এর বাগ লুকিয়ে থাকে ঠিক সেই জায়গায়, যেখানে উদাহরণ পৌঁছায় না: খালি list, এক node-এর list, head মোছা, শেষ node, আর cycle. তাছাড়া এখানে একটি pointer-এর ভুল ক্রম পুরো list হারিয়ে দেয়, অথচ ছোট পরীক্ষায় সেটি ধরা পড়ে না।

Linked list-এ AI সাধারণত যেসব ভুল করে

AI-এর ভুলকীভাবে ধরবেন
head মোছার বা খালি list-এর ক্ষেত্র বাদ — head->next পড়ার আগে null যাচাই নেইখালি, এক node, দুই node — এই তিনটি list দিয়ে প্রতিটি ফাংশন চালান
“linked list-এ delete O(1)” — কিন্তু কোডে আগে search করা হচ্ছেলুপ গুনুন: খুঁজতে হাঁটলে মোট O(n)
তীর বদলানোর ভুল ক্রম (p->next = n আগে, n->next = p->next পরে) — node নিজেকেই দেখায়কাগজে বাক্স-তীর এঁকে প্রতিটি লাইনের পরের ছবি মেলান
C++-এ delete ভুলে যাওয়া, বা delete করা node-এর next পড়াAddressSanitizer (-fsanitize=address) বা Valgrind দিয়ে চালান
রিকার্সিভ reverse-কে “O(1) জায়গা” বলা; Python-এ লাখ node-এ রিকার্শনcall stack-এর গভীরতা হিসাব করুন — n; Python-এ ১০০০-এর বেশি হলে RecursionError
Floyd-এর পর্যায় ২-এ fast-কে দুই ধাপে চালানো, বা fast->next যাচাই বাদলেজ ছাড়া cycle (μ = 0), এক node-এর self-loop, আর cycle ছাড়া list — তিনটিতেই পরীক্ষা

AI-কে সমাধান লিখতে না বলে বরং আপনার কোডের দুর্বল জায়গা খুঁজতে বলুন, আর তার দাবিগুলোও যাচাই করুন:

আমি DSA শিখছি, এখন linked list. নিচে আমার লেখা একটি linked list ফাংশন দিলাম: [এখানে আপনার কোড পেস্ট করুন] পুরো সমাধান লিখে দিও না। শুধু এগুলো করো: ১. খালি list, এক node-এর list আর head মুছতে হলে — প্রতিটিতে আমার কোড কী করবে, ধাপে ধাপে বলো। ২. ৩ node-এর একটি list-এ প্রতিটি লাইনের পর বাক্স-তীরের ছবি (ASCII) এঁকে দেখাও। ৩. সময় আর বাড়তি জায়গার complexity কত, কেন — call stack থাকলে সেটিও ধরো। ৪. C++ হলে কোথাও memory leak বা delete করা node ব্যবহারের সম্ভাবনা আছে কি না, শুধু ইঙ্গিত দাও।

Linked list কোড যাচাইয়ের চেকলিস্ট

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

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

প্র ০১Linked list-এর জন্ম কোথায়, আর LISP কেন পুরো একটি ভাষা এর ওপর দাঁড় করাল?

১৯৫৫–৫৬ সালে RAND Corporation আর Carnegie Institute of Technology-তে Allen Newell, Cliff Shaw ও Herbert Simon এমন একটি প্রোগ্রাম লিখছিলেন, যা গাণিতিক যুক্তির উপপাদ্য প্রমাণ করবে — “Logic Theorist”, যাকে প্রায়ই প্রথম artificial intelligence প্রোগ্রামগুলোর একটি বলা হয়। সমস্যা ছিল: প্রমাণ চলাকালীন কতগুলো সূত্র তৈরি হবে, কত বড় হবে, আগে থেকে জানা অসম্ভব। স্থির আকারের array দিয়ে এটি সামলানো যেত না। তাঁরা একটি নতুন ভাষা বানালেন, IPL (Information Processing Language), যার কেন্দ্রে ছিল list — প্রতিটি ঘরে ডেটা আর পরের ঘরের ঠিকানা। মেমরির খালি ঘরগুলোও তাঁরা একটি list-এ রাখতেন (“available space list”), যেখান থেকে দরকারমতো ঘর নেওয়া আর ফেরত দেওয়া হতো — আজকের heap allocator-এর পূর্বসূরি।

১৯৫৮ সালে MIT-তে John McCarthy এই ধারণাকে এক ধাপ এগিয়ে নেন LISP-এ (নামটিই এসেছে “LISt Processing” থেকে)। LISP-এ মৌলিক একক হলো cons cell: দুটি ঘরের একটি জোড়া। প্রথম ঘর car (মান), দ্বিতীয় ঘর cdr (বাকি list) — ঠিক আমাদের val আর next. নাম দুটি এসেছে IBM 704 কম্পিউটারের একটি মেমরি-শব্দের দুই অংশের নাম থেকে (“contents of the address/decrement part of register”). LISP-এ প্রোগ্রাম নিজেও list, ডেটাও list — তাই প্রোগ্রাম অন্য প্রোগ্রাম তৈরি ও বদলাতে পারে, যা পরবর্তী কয়েক দশক AI গবেষণার প্রিয় ভাষা হিসেবে LISP-কে প্রতিষ্ঠিত করে।

আরেকটি বিশাল উত্তরাধিকার: list-এর node কখন আর লাগবে না, এটা প্রোগ্রামারকে দিয়ে হিসাব করানো LISP-এ প্রায় অসম্ভব ছিল — একই node অনেক list-এ ভাগাভাগি হতো। তাই McCarthy LISP-এর জন্য স্বয়ংক্রিয় মেমরি পুনরুদ্ধার উদ্ভাবন করেন — garbage collection-এর জন্ম এখানেই। আজ Python, Java, JavaScript, Go — সবার garbage collector এই ধারার উত্তরসূরি।

মূল উপলব্ধি: Linked list জন্মেছিল একটি বাস্তব প্রয়োজন থেকে — “আকার আগে থেকে জানি না” — আর সেই প্রয়োজন মেটাতে গিয়েই এসেছে dynamic memory, free list আর garbage collection. একটি ডেটা স্ট্রাকচার কখনো কখনো পুরো একটি প্রযুক্তির ধারা তৈরি করে।

প্র ০২দুটিই O(n) — তবু vector-এ হাঁটা linked list-এ হাঁটার চেয়ে বহু গুণ দ্রুত কেন? Big-O কি তাহলে মিথ্যা বলে?

Big-O মিথ্যা বলে না — সে শুধু ধ্রুবক লুকায়, আর পর্ব ০২-এর RAM model ধরে নেয় যে মেমরির যেকোনো ঘর পড়তে সমান সময় লাগে। আধুনিক কম্পিউটারে এই অনুমান মোটেই সত্য নয়। CPU-র register থেকে পড়া প্রায় তাৎক্ষণিক, L1 cache থেকে কয়েক cycle, L2-L3 থেকে কয়েক ডজন, আর মূল মেমরি (RAM) থেকে প্রায়ই একশো cycle বা তার বেশি। অর্থাৎ “একটি মেমরি পড়া” কখনো ১ একক, কখনো ১০০ একক।

vector-এর উপাদান পাশাপাশি থাকে। প্রথমটি পড়তে গিয়ে CPU পুরো 64 byte-এর cache line আনে — একসাথে ১৬টি int. তার ওপর hardware prefetcher টের পায় যে আপনি ক্রমানুসারে পড়ছেন, আর পরের line-গুলো আগেভাগে আনতে থাকে। ফলে প্রায় প্রতিটি পড়া L1 থেকে আসে। compiler-ও এই লুপকে SIMD নির্দেশ দিয়ে একসাথে কয়েকটি উপাদানে চালাতে পারে।

linked list-এ প্রতিটি node মেমরির এলোমেলো জায়গায়। আরও খারাপ: পরের node-এর ঠিকানা জানা যায় কেবল বর্তমান node পড়া শেষ হলে — একে বলে “pointer chasing”. CPU তাই আগেভাগে আনতেও পারে না, প্রতিটি ধাপে অপেক্ষা করে। node-গুলো একটানা তৈরি হলে allocator হয়তো সেগুলো কাছাকাছি রাখে, কিন্তু অনেক insert-delete-এর পর তারা ছড়িয়ে পড়ে। বাস্তব মাপজোকে বড় list-এ traversal vector-এর চেয়ে কয়েক গুণ থেকে দশ গুণেরও বেশি ধীর হওয়া অস্বাভাবিক নয় — যন্ত্র আর ডেটার আকারভেদে।

এজন্য গবেষকেরা “cache-aware” আর “cache-oblivious” অ্যালগরিদম নিয়ে কাজ করেন, যেখানে মেমরির স্তরগুলো বিশ্লেষণের অংশ। ব্যবহারিক সমাধানও আছে: unrolled linked list, যেখানে প্রতিটি node-এ একটি ছোট array (ধরুন ৬৪টি উপাদান) থাকে — list-এর নমনীয়তা আর array-র locality দুটোই আংশিক পাওয়া যায়। CPython-এর deque ঠিক এমন নকশা। B-tree (পর্ব ৩০)-ও একই চিন্তার ফল — প্রতিটি node-এ অনেক key, যাতে ডিস্ক বা মেমরির একেকটি পড়ায় বেশি কাজ হয়।

মূল উপলব্ধি: Big-O বলে বৃদ্ধির হার কেমন; কোনটি আসলে দ্রুত, তা বলে ধ্রুবক আর হার্ডওয়্যার। একই complexity-র দুটি সমাধানের মধ্যে বেছে নিতে মেমরির বিন্যাস নিয়ে ভাবুন — আর সন্দেহ হলে মেপে দেখুন।

প্র ০৩Floyd-এর “কচ্ছপ ও খরগোশ” কে আবিষ্কার করেন, আর linked list ছাড়া এটি আর কোথায় কাজে লাগে?

অ্যালগরিদমটি সাধারণত Robert W. Floyd-এর নামে পরিচিত — সেই Floyd, যিনি shortest path-এর Floyd-Warshall অ্যালগরিদম (পর্ব ৪৪) আর প্রোগ্রামের শুদ্ধতা প্রমাণের ভিত্তিমূলক কাজের জন্য ১৯৭৮ সালে Turing Award পান। মজার ব্যাপার, cycle detection-এর এই কৌশল নিয়ে Floyd আলাদা কোনো প্রবন্ধ প্রকাশ করেছিলেন বলে জানা যায় না; Donald Knuth তাঁর The Art of Computer Programming-এর দ্বিতীয় খণ্ডে (random number নিয়ে আলোচনায়) এটিকে Floyd-এর কৌশল বলে উল্লেখ করেন, আর সেখান থেকেই নামটি ছড়ায়।

আসল প্রেক্ষাপট linked list ছিল না। একটি ফাংশন f আর শুরুর মান x₀ থেকে x₁ = f(x₀), x₂ = f(x₁), … ক্রমটি ভাবুন। f-এর সম্ভাব্য মান সসীম হলে ক্রমটি একসময় পুনরাবৃত্তি করবেই — ঠিক গ্রিক অক্ষর “ρ” (rho)-এর আকারে: একটি লেজ, তারপর একটি বৃত্ত। linked list আসলে এর একটি বিশেষ ক্ষেত্র, যেখানে f(node) = node-এর next. কম্পিউটারের pseudo-random number generator এমনই একটি f; তার চক্র কত লম্বা (λ), অর্থাৎ কত সংখ্যা পরে সংখ্যাগুলো আবার একই ক্রমে ফিরে আসবে — এটি জানা খুব জরুরি, আর বিশাল ক্রম মেমরিতে না রেখে O(1) জায়গায় মাপতে Floyd-এর কৌশলই কাজে লাগে।

John Pollard ১৯৭৫ সালে এর ওপর ভিত্তি করে বড় সংখ্যার উৎপাদক খোঁজার “rho” পদ্ধতি দেন — f(x) = x² + c mod n ক্রমে cycle খুঁজে n-এর একটি উৎপাদক বের করা হয়। একই ধারণা cryptography-তে discrete logarithm আক্রমণে আর hash function-এর collision খুঁজতেও ব্যবহৃত হয়। ১৯৮০ সালে Richard Brent একটি উন্নত সংস্করণ দেন, যা গড়ে কম ধাপ নেয় এবং Pollard-এর পদ্ধতিকেও দ্রুত করে।

ইন্টারভিউর জগতে একটি বিখ্যাত ধাঁধা আছে: n + 1 দৈর্ঘ্যের একটি array-তে ১ থেকে n পর্যন্ত সংখ্যা, একটি সংখ্যা পুনরাবৃত্ত — array না বদলে আর O(1) জায়গায় সেটি খুঁজুন। সমাধান: i → a[i]-কে একটি linked list ধরে Floyd চালান; cycle-এর শুরুই পুনরাবৃত্ত সংখ্যা। এটি দেখায়, “linked list” কখনো কখনো মেমরিতে না থেকেও একটি চিন্তার কাঠামো।

মূল উপলব্ধি: দুই গতির দুটি pointer — এই সরল ধারণাটি linked list ছাড়িয়ে যেকোনো “পরের অবস্থা কী” প্রশ্নে খাটে। একটি অ্যালগরিদমকে তার আসল গাণিতিক কাঠামোয় চিনতে পারলে তার প্রয়োগ অনেক গুণ বেড়ে যায়।

প্র ০৪Linux kernel-এর linked list আমাদের বইয়ের সাধারণ list থেকে আলাদা কেন — “intrusive list” কী?

সাধারণ বইয়ের list-এ node ডেটাকে ধারণ করে: struct Node { int val; Node* next; }. ডেটা ভেতরে, শিকল বাইরে থেকে বানানো। Linux kernel উল্টো পথে যায়। include/linux/list.h-এ একটি ছোট struct আছে, struct list_head { struct list_head *next, *prev; } — এতে কোনো ডেটাই নেই। বরং যে বস্তুকে list-এ রাখতে হবে (যেমন একটি process-এর বিবরণ), তার ভেতরেই একটি list_head সদস্য বসানো হয়। একে বলে intrusive list — শিকলের আংটা বস্তুর গায়েই লাগানো।

লাভ কী? প্রথমত, আলাদা node-এর জন্য কোনো বাড়তি বরাদ্দ লাগে না — বস্তু তৈরি হলেই তার আংটাও তৈরি। দ্বিতীয়ত, একই বস্তুর ভেতরে একাধিক list_head থাকতে পারে, ফলে একটি process একসাথে কয়েকটি list-এ থাকতে পারে (যেমন সব process-এর তালিকা আর কোনো নির্দিষ্ট অপেক্ষার সারি)। তৃতীয়ত, বস্তুর pointer হাতে থাকলেই O(1)-এ তাকে যেকোনো list থেকে বের করা যায় — কোনো খোঁজ ছাড়া। আংটার ঠিকানা থেকে পুরো বস্তুর ঠিকানা বের করতে kernel container_of নামের একটি macro ব্যবহার করে, যা struct-এর ভেতরে সদস্যটির অবস্থান (offset) বিয়োগ করে।

kernel-এর list সবসময় circular doubly linked list with a sentinel: একটি head list_head থাকে, যা নিজে কোনো বস্তুর অংশ নয়; খালি list-এ তার next আর prev দুটোই নিজেকে দেখায়। ফলে insert আর delete-এ কোনো null যাচাই নেই — অংশ ৪ আর ৫-এর sentinel-এর ধারণাই এখানে শিল্পমানে প্রয়োগ। একই ধরনের intrusive list গেম ইঞ্জিন আর উচ্চ-কর্মক্ষমতার সার্ভারেও জনপ্রিয়, আর C++-এ Boost.Intrusive লাইব্রেরি এই নকশা দেয়।

দাম আছে: বস্তুকে আগে থেকেই জানতে হয় সে কোন কোন list-এ থাকবে, আর ভুল করে একটি বস্তু মুছে ফেললে কিন্তু list থেকে বের না করলে বাকি list-এ ঝুলন্ত pointer থেকে যায়। kernel এজন্য মোছা node-এর pointer-এ ইচ্ছাকৃত “বিষাক্ত” মান (poison) বসায়, যাতে ভুল ব্যবহার দ্রুত crash করে ধরা পড়ে।

মূল উপলব্ধি: একই ডেটা স্ট্রাকচারের অনেক বাস্তবায়ন সম্ভব। বাস্তব সিস্টেমে “মেমরি কোথায় যাচ্ছে” আর “কোন কাজ সবচেয়ে ঘন ঘন হয়” — এই দুটি প্রশ্নই নকশা ঠিক করে, বইয়ের সংজ্ঞা নয়।

প্র ০৫নিরাপদ ভাষাগুলোতে doubly linked list লেখা কঠিন কেন — আর Rust এই সমস্যা নিয়ে কী বলে?

C++-এ doubly linked list-এর প্রতিটি node-কে দুজন দেখায় — আগের node-এর next আর পরের node-এর prev. তাহলে node-টির “মালিক” কে? কে তাকে মুছবে? smart pointer দিয়ে উত্তর দিতে গেলে সমস্যা দেখা দেয়। দুই দিকেই shared_ptr দিলে প্রতিটি পাশাপাশি জোড়া একে অপরকে ধরে রাখে — reference count কখনো শূন্য হয় না, পুরো list leak করে। প্রচলিত সমাধান: next হবে মালিক (unique_ptr বা shared_ptr), আর prev হবে মালিকানাহীন — একটি সাধারণ pointer বা weak_ptr. অর্থাৎ ডেটা স্ট্রাকচারের নকশাতেই মালিকানার দিক ঠিক করতে হয়।

Rust ভাষায় এই প্রশ্ন আরও তীক্ষ্ণ। Rust-এর compiler (borrow checker) নিয়ম করে: একটি মানের একজনই মালিক, আর কোনো মুহূর্তে হয় একাধিক পড়ার reference, নয়তো একটিমাত্র লেখার reference — দুটো একসাথে নয়। singly list এই নিয়মে স্বাভাবিকভাবে মেলে (Option<Box<Node>>), কিন্তু doubly list-এ প্রতিটি node-কে দুই দিক থেকে বদলাতে হয়, যা সরাসরি এই নিয়ম ভাঙে। ফলে নিরাপদ Rust-এ doubly list লিখতে Rc<RefCell<…>> আর Weak-এর মতো জটিল কাঠামো লাগে, অথবা unsafe ব্লকে কাঁচা pointer — Rust-এর standard library-র LinkedList ভেতরে ঠিক তাই করে। Rust সম্প্রদায়ে এ নিয়ে একটি জনপ্রিয় অনলাইন টিউটোরিয়ালই আছে, “Learning Rust With Entirely Too Many Linked Lists”, যা linked list দিয়ে ভাষার মালিকানার নিয়ম শেখায়।

Garbage-collected ভাষায় (Python, Java) এই সমস্যা নেই — prev আর next দুটোই সাধারণ reference, আর cycle থাকলেও tracing বা cycle-collecting GC ঠিকই মুছে দেয়। বিনিময়ে মেমরি কখন মুক্ত হবে তা নিশ্চিত নয়, আর GC-র নিজের সময় ও মেমরির খরচ আছে।

এখান থেকে একটি গভীর সত্য বেরিয়ে আসে: যে ডেটা স্ট্রাকচারে একটি বস্তুকে একাধিক জায়গা থেকে দেখানো হয় (doubly list, গ্রাফ, cycle-যুক্ত কাঠামো), সেখানে মেমরি ব্যবস্থাপনা স্বভাবতই কঠিন। এজন্যই কনটেস্ট আর অনেক বাস্তব কোডে গ্রাফ রাখা হয় index দিয়ে (array-র অবস্থান), pointer দিয়ে নয় — মালিক তখন একটিই, array নিজে।

মূল উপলব্ধি: Linked list শুধু অ্যালগরিদমের শিক্ষা নয়, মালিকানা আর মেমরি নিরাপত্তারও শিক্ষা। “কে কাকে দেখায়, আর কে কাকে মুছবে” — এই প্রশ্নের উত্তর যত জটিল, বাগের সম্ভাবনা তত বেশি।

অনুশীলন

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

  1. হাতে trace করুন: খালি একটি singly list-এ পরপর এই কাজগুলো হলো — pushFront(3), pushFront(7), 7-এর node-এর পরে 5 insert, pushFront(1), তারপর মান 3-এর node delete. শেষে list কেমন? প্রতিটি কাজে কয়টি pointer লিখতে হলো?

    ধাপে ধাপে: [3] → null; তারপর [7] → [3]; তারপর [7] → [5] → [3]; তারপর [1] → [7] → [5] → [3]; শেষে 3 মুছে [1] → [7] → [5] → null.

    pointer লেখা: প্রতিটি pushFront-এ ২টি (নতুন node-এর next, আর head); insert after-এ ২টি (নতুন node-এর next, আর 7-এর next); delete-এ ১টি (5-এর next = null), তবে তার আগে 5-কে খুঁজতে head থেকে হাঁটতে হয়েছে — ৩টি node পেরিয়ে। তাই তীর বদলানো O(1), কিন্তু মান দিয়ে delete মোট O(n).

  2. Floyd হাতে চালান: list 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8, আর 8-এর next আবার 4. slow ও fast কোন node-এ, কত নম্বর ধাপে প্রথম মিলবে? তারপর পর্যায় ২ কোথায়, কত ধাপে শেষ হবে? μ আর λ কত?

    μ = 3 (node 1, 2, 3), λ = 5 (4, 5, 6, 7, 8). পর্যায় ১: ধাপ ১ — s = 2, f = 3; ধাপ ২ — s = 3, f = 5; ধাপ ৩ — s = 4, f = 7; ধাপ ৪ — s = 5, f = 4 (7 → 8 → 4); ধাপ ৫ — s = 6, f = 6 (4 → 5 → 6). ধাপ ৫-এ node 6-এ মিলন। সূত্রের সাথে মেলে: প্রথম t ≥ μ = 3, যা λ = 5 দিয়ে বিভাজ্য — t = 5.

    পর্যায় ২: s = 1, f = 6 থেকে এক ঘর করে — (2, 7), (3, 8), (4, 4). ৩ ধাপে node 4-এ মিলন — ৩ = μ, আর 4-ই cycle-এর প্রথম node.

  3. চিন্তা করুন: একটি singly list-এর শুধু একটি মাঝের node-এর pointer আপনাকে দেওয়া হলো — head নয়। সেই node-টি list থেকে “মুছে” দিন O(1)-এ। কৌশলটি কেন শেষ node-এর জন্য চলে না? আর এই কৌশল কোন পরিস্থিতিতে বিপজ্জনক?

    আগের node জানা নেই, তাই তার তীর বদলানো অসম্ভব। কৌশল: পরের node-এর মান এই node-এ কপি করুন, তারপর পরের node-টি মুছুন — t = x->next; x->val = t->val; x->next = t->next; delete t;. ফলাফল দেখতে এমন, যেন x মুছে গেছে। O(1).

    শেষ node-এর কোনো “পরের node” নেই, তাই কপি করার কিছু নেই — আর আগের node-এর next-কে null করার উপায়ও নেই। বিপদ: প্রোগ্রামের অন্য কোথাও যদি পরের node-টির pointer রাখা থাকে (যেমন একটি LRU cache-এর hash table-এ), সেটি এখন মুছে ফেলা মেমরি দেখায় — dangling pointer. আসলে মুছল একটি node, আর মান বদলাল আরেকটির; বাস্তব সিস্টেমে তাই এই কৌশল সাধারণত গ্রহণযোগ্য নয়, এটি মূলত ইন্টারভিউর ধাঁধা।

  4. কোড লিখুন: একটি singly list palindrome কি না (সামনে থেকে আর পেছন থেকে একই — যেমন 1 → 2 → 3 → 2 → 1) বের করুন, O(n) সময়ে আর O(1) বাড়তি জায়গায়। এই পর্বের কোন কোন কৌশল লাগবে? (C++ বা Python — আপনার পছন্দ।)

    তিনটি কৌশল জোড়া লাগে: (১) slow/fast দিয়ে মাঝখান খোঁজা, (২) দ্বিতীয় অর্ধেক in-place উল্টানো, (৩) দুই অর্ধেক একসাথে তুলনা। চাইলে শেষে দ্বিতীয় অর্ধেক আবার উল্টে list আগের অবস্থায় ফেরানো ভদ্রতা।

    bool isPalindrome(Node* head) {
        Node *slow = head, *fast = head;
        while (fast && fast->next) { slow = slow->next; fast = fast->next->next; }
        Node* second = reverse(slow);          // reverse from the middle on
        bool ok = true;
        for (Node *p = head, *q = second; q; p = p->next, q = q->next)
            if (p->val != q->val) { ok = false; break; }
        reverse(second);                       // restore the original list
        return ok;
    }

    কেন কাজ করে: n বিজোড় হলে slow ঠিক মাঝের node, দ্বিতীয় অর্ধেক তাকে দিয়ে শুরু; জোড় হলে slow দ্বিতীয় মাঝের node. তুলনা চলে q (ছোট বা সমান অর্ধেক) শেষ না হওয়া পর্যন্ত। উল্টানোর পরেও প্রথম অর্ধেকের শেষ node এখনো পুরোনো মাঝের node-কে দেখায়, তাই p কখনো null-এ পড়ে না। সময় Θ(n) — তিনটি পাস; বাড়তি জায়গা O(1). সহজ কিন্তু O(n) জায়গার বিকল্প: সব মান একটি array-তে কপি করে দুই প্রান্ত থেকে তুলনা — stress testing-এ এটিকেই brute force হিসেবে ব্যবহার করুন।

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

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

ক্রমFloyd-এর পদ্ধতিতে linked list-এর cycle-এর শুরু বের করার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. slow আর fast দুটিকেই head-এ রাখা
  2. slow-কে এক ধাপ আর fast-কে দুই ধাপ করে চালানো, যতক্ষণ না মেলে বা fast null-এ পৌঁছায়
  3. fast null-এ পৌঁছালে “cycle নেই” বলে থামা; মিললে cycle নিশ্চিত
  4. slow-কে আবার head-এ পাঠানো, fast মিলনস্থলেই থাকা
  5. দুটিকেই এক ধাপ করে চালানো; যেখানে মেলে, সেটিই cycle-এর প্রথম node

n টি node-এর একটি singly linked list-এ শুধু head pointer জানা। k-তম node (1 ≤ k ≤ n) পড়ার time complexity কত?

  • O(1)
  • O(log k)
  • Θ(k)
  • Θ(k²)

একটি linked list-এর একটি মাঝের node-এর pointer x হাতে আছে, head নয়। x-কে (মান কপি করার কৌশল ছাড়া) list থেকে বের করে দিতে কোন ধরনের list-এ O(1) সময় লাগে?

  • Singly linked list
  • Doubly linked list
  • Circular singly linked list (prev pointer ছাড়া)
  • কোনোটিতেই সম্ভব নয়

Singly linked list-এ p node-এর পরে নতুন node n বসাতে নিচের কোন ক্রমটি সঠিক?

  • p->next = n; n->next = p->next;
  • p->next = n; n->next = nullptr;
  • n->next = p; p = n;
  • n->next = p->next; p->next = n;

1 → 2 → 3 → 4 → 5 → 6 list-এ slow ও fast দুটিই head থেকে শুরু করে, আর while (fast && fast->next) { slow = slow->next; fast = fast->next->next; } লুপ শেষ হলে slow কোন node-এ থাকে?

  • 4
  • 3
  • 5
  • null

Floyd-এর tortoise and hare পদ্ধতিতে n node-এর list-এ cycle খুঁজতে সময় আর বাড়তি জায়গা কত?

  • সময় O(n), জায়গা O(n)
  • সময় O(n²), জায়গা O(1)
  • সময় O(n), জায়গা O(1)
  • সময় O(log n), জায়গা O(1)

দশ লাখ int-এর ওপর শুধু একবার শুরু থেকে শেষ পর্যন্ত হেঁটে যোগফল বের করতে হবে। vector আর linked list দুটিতেই এটি Θ(n), তবু বাস্তবে vector সাধারণত অনেক দ্রুত। প্রধান কারণ কী?

  • vector-এর traversal আসলে O(log n)
  • vector-এর উপাদান মেমরিতে পাশাপাশি, তাই cache locality ভালো
  • linked list-এর কোড compiler অপটিমাইজ করতে পারে না
  • linked list-এ প্রতিবার পড়ার সময় node নতুন করে তৈরি হয়

শব্দকোষ

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

মনে রাখুন

  1. Linked list = node (মান + next) আর head; শেষ node-এর next null. পৌঁছাতে হাঁটতে হয় — k-তম node Θ(k), search Θ(n).
  2. তীর বদলানো O(1): আগে নতুন node-এর তীর, পরে পুরোনোটির। node বা তার আগেরটি হাতে না থাকলে খোঁজার খরচ O(n) যোগ হয়।
  3. Dummy head বিশেষ ক্ষেত্র দূর করে; doubly list-এ node হাতে থাকলে delete O(1); circular list চক্রাকার কাজে (round-robin, Josephus).
  4. Big-O-তে list জেতে, বাস্তবে প্রায়ই vector — cache locality আর কম মেমরি। C++-এ প্রতিটি new-এর একটি delete; Python-এ garbage collector.
  5. Reverse: prev, cur, nxt — O(n), O(1). মাঝখান ও cycle: slow/fast; Floyd মিলনের পর slow-কে head-এ ফিরিয়ে এক ধাপ করে চালালে cycle-এর শুরু।

পরের পর্বে: Linked list-এর শুরুতে যোগ আর শুরু থেকে মোছা — দুটিই O(1). শুধু এই দুটি কাজ দিয়েই তৈরি হয় একটি ডেটা স্ট্রাকচার, যা আপনি রিকার্শনের call stack-এ ইতিমধ্যে দেখেছেন: stack — শেষে ঢোকে, আগে বের হয়। বন্ধনী মেলানো, undo আর expression হিসাব দিয়ে।

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

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