সরাসরি পর্বে যান
পর্ব ৩৩ · ৫৬-এর মধ্যে · মডিউল ৫

লিংকড লিস্ট — মেমরিতে ছড়ানো ঘর, পয়েন্টারে বাঁধা শিকল

Linked Lists — Nodes, Pointers, Insertion, Deletion & the Cache

Taught in Bangla, this lesson builds linked lists in C: why arrays fall short, creating nodes on the heap, traversal, insertion and deletion, freeing a list, classic bugs, doubly and circular lists, and why arrays often win in practice because of the cache.

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

  • Array কোথায় আটকে যায়, আর linked list সেই সমস্যা কীভাবে অন্যভাবে সমাধান করে
  • Node, head pointer আর NULL দিয়ে মেমরিতে একটি তালিকা কীভাবে দাঁড়ায়
  • ঢোকানো, মুছে ফেলা আর পুরো তালিকা free করা — কোন pointer কোন ক্রমে বদলাতে হয়, আর কেন
  • Doubly ও circular list, আর cache-এর কারণে কেন বাস্তবে array প্রায়ই দ্রুত
  • Linux kernel, LRU cache আর malloc-এর ভেতরে linked list-এর ব্যবহার, সাথে AI-এর কোড যাচাইয়ের চেকলিস্ট

১ · Array-র দুই সমস্যা — বাঁধা আকার, মাঝে ঢোকানোর খরচ

নতুন মডিউলে স্বাগতম। এই মডিউলে আমরা একে একে কয়েকটি মৌলিক ডেটা স্ট্রাকচার নিজ হাতে C-তে বানাব — linked list, stack, queue, tree, heap, hash table, graph. আমাদের নজর থাকবে মেমরিতে আসলে কী ঘটছে আর pointer কীভাবে নড়ছে, সেই দিকে। অ্যালগরিদমের আরও গভীর আলোচনা চাইলে ABCL TECH-এর আলাদা Data Structures & Algorithms কোর্স আছে; এই পর্বগুলো তার ভিত্তি গড়ে দেবে।

শুরু করি একটি পরিচিত জিনিস দিয়ে — array (পর্ব ১৬). Array-র ঘরগুলো মেমরিতে গায়ে গায়ে লাগানো (contiguous memory), তাই marks[7] সরাসরি এক লাফে পাওয়া যায়। এটি array-র সবচেয়ে বড় শক্তি। কিন্তু দুটি জায়গায় সে আটকে যায়।

প্রথম সমস্যা — আকার বাঁধা। int marks[50]; লিখলে ৫০টির বেশি ঘর পাবেন না। ক্লাসে ৫১তম ছাত্র এলে? পর্ব ২০-এ দেখেছেন, dynamic array realloc করে বড় হতে পারে — কিন্তু তখন পুরো ব্লক নতুন জায়গায় কপি হতে পারে, আর পুরোনো সব pointer অচল হয়ে যায়।

দ্বিতীয় সমস্যা — মাঝে ঢোকানো। ধরুন রোল নম্বর অনুযায়ী সাজানো নম্বরের তালিকা: [55, 64, 85, 90]. এখন 70 বসাতে চান 64-এর পরে। Array-তে ফাঁকা জায়গা বানাতে 85 আর 90 — দুজনকেই এক ঘর ডানে সরাতে হবে:

আগে :  [55] [64] [85] [90] [  ]
সরানো:  [55] [64] [  ] [85] [90]    <- 85, 90 এক ঘর করে ডানে
পরে :  [55] [64] [70] [85] [90]

বিয়েবাড়ির একটি লম্বা বেঞ্চ, সবাই গা ঘেঁষে বসে আছেন। এক মুরুব্বি এলেন, তাঁকে বসাতে হবে ঠিক মাঝখানে। এখন তাঁর ডান পাশের প্রত্যেককে এক জন করে সরে বসতে হবে। বেঞ্চে ৫ জন থাকলে ঝামেলা কম, কিন্তু ৫০,০০০ জন থাকলে? এক জনকে বসাতে হাজার হাজার মানুষকে নড়তে হবে। Array-র মাঝে ঢোকানো ঠিক এমন — তালিকা যত বড়, খরচ তত বেশি।

তাহলে উল্টো চিন্তা করি: যদি ঘরগুলোকে পাশাপাশি বসিয়ে রাখার বাধ্যবাধকতাই না থাকে? প্রত্যেকে যেখানে খুশি বসুক, শুধু জানুক তার পরে কে আছে। তাহলে নতুন কাউকে মাঝে বসাতে কাউকেই সরতে হবে না — শুধু দুজনের “পরে কে” তথ্যটা বদলালেই হলো। এই চিন্তাটির নামই linked list.

২ · Linked list — ছড়ানো ঘর, ঠিকানার শিকল

Linked list-এর প্রতিটি ঘরকে বলে node. প্রতিটি node-এ দুটি জিনিস থাকে: ডেটা (যেমন একটি নম্বর) আর পরের node-এর ঠিকানা — একটি pointer, যার নাম সাধারণত next. Node-গুলো heap-এর যেখানে জায়গা পাওয়া গেছে সেখানেই থাকে; একটি আরেকটির পাশে থাকার কোনো দরকার নেই।

একটি linked list — head থেকে NULL পর্যন্ত

Each node stores its data and the address of the next node

  1. head0x5a20প্রথম node-এর ঠিকানা
  2. 72next = 0x91f0ঠিকানা 0x5a20
  3. 85next = 0x3c40ঠিকানা 0x91f0
  4. 64next = NULLঠিকানা 0x3c40
  5. NULL(void *)0তালিকা এখানে শেষ
ছবি ৩৩.১ — ঠিকানাগুলো এলোমেলো, কিন্তু next ধরে ধরে গেলে ক্রম ঠিকই থাকে: ৭২ → ৮৫ → ৬৪.

স্কুলের বনভোজনে “গুপ্তধন খোঁজা” খেলার কথা মনে আছে? প্রথম চিরকুট থাকে শিক্ষকের হাতে। তাতে লেখা, “পরের চিরকুট আমগাছের নিচে।” আমগাছের নিচের চিরকুটে লেখা, “পরেরটি পানির ট্যাংকের পাশে।” শেষ চিরকুটে লেখা, “এখানেই শেষ।” চিরকুটগুলো মাঠজুড়ে ছড়ানো, কিন্তু প্রতিটি পরেরটির ঠিকানা জানে। শিক্ষকের হাতের প্রথম চিরকুটটি হলো head, প্রতিটি চিরকুট একটি node, আর “এখানেই শেষ” হলো NULL. প্রথম চিরকুট হারালে? পুরো খেলাই শেষ — বাকিগুলো কোথায়, কেউ জানে না।

আরেকটি ছবি: রেলগাড়ির বগি। প্রতিটি বগি শুধু তার পরের বগির সাথে হুকে আটকানো। মাঝে একটি নতুন বগি জুড়তে পুরো ট্রেন ভাঙতে হয় না — একটি হুক খুলে নতুন বগিকে দুই পাশে লাগালেই হয়।

C-তে node দেখতে কেমন

struct Node {
    int marks;            /* ডেটা */
    struct Node *next;    /* পরের node-এর ঠিকানা, না থাকলে NULL */
};

struct Node *head = NULL; /* খালি তালিকা */

খেয়াল করুন, struct Node-এর ভেতরে আছে struct Node * — নিজের টাইপেরই একটি pointer. একে বলে self-referential struct. ভেতরে পুরো struct Node রাখা সম্ভব নয় (তাহলে struct-টি অসীম বড় হয়ে যেত), কিন্তু তার ঠিকানা রাখা যায়, কারণ যেকোনো pointer-এর আকার নির্দিষ্ট — ৬৪-বিট কম্পিউটারে সাধারণত ৮ byte (পর্ব ২২-এর struct মনে করুন)।

মূল ধারণা

Linked list-এর পুরো জগৎ তিনটি নিয়মে বাঁধা: (১) head প্রথম node-এর ঠিকানা রাখে; খালি তালিকায় head == NULL. (২) প্রতিটি node-এর next পরের node-এর ঠিকানা রাখে। (৩) শেষ node-এর next হলো NULL. প্রতিটি অপারেশন শেষে এই তিনটি নিয়ম সত্য থাকতে হবে — নইলে তালিকা ভাঙা।

এই ধরনের তালিকায় প্রতিটি node কেবল সামনের দিকে চেনে, তাই একে বলে singly linked list. পেছনের দিকেও চেনে এমন তালিকা পরে দেখব (অংশ ৭)।

Linked list-এর ধারণা এসেছিল ১৯৫০-এর দশকের মাঝামাঝি। RAND Corporation-এ Allen Newell, Cliff Shaw ও Herbert Simon কৃত্রিম বুদ্ধিমত্তার প্রথম দিকের প্রোগ্রাম লিখতে গিয়ে “Information Processing Language” (IPL) তৈরি করেন, যার মূল কাঠামোই ছিল list. কিছুদিন পরে ১৯৫৮ সালে John McCarthy তৈরি করেন LISP — নামটাই এসেছে “LISt Processing” থেকে। অর্থাৎ AI আর linked list-এর সম্পর্ক বহু পুরোনো।

৩ · Node বানানো আর তালিকা ঘুরে দেখা

প্রতিটি node তৈরি হয় চলার সময়, malloc দিয়ে heap থেকে জায়গা নিয়ে (পর্ব ২০). তারপর তাকে তালিকায় জোড়া হয়। সবচেয়ে সহজ জায়গা হলো শুরুতে জোড়া — মাত্র দুটি লাইন:

n->next = head;   /* ১: নতুন node এখনকার প্রথম node-কে ধরল */
head = n;         /* ২: head এবার নতুন node-কে দেখায় */

এখানে n->next হলো arrow operator — “n যে struct-কে দেখায়, তার next”. তালিকা খালি থাকলেও এই দুই লাইন ঠিক কাজ করে: তখন head ছিল NULL, তাই নতুন node-এর next হয় NULL — সেটিই একমাত্র এবং শেষ node. এমন কোড ভালো, যেখানে খালি তালিকার জন্য আলাদা if লাগে না।

পুরো তালিকা একে একে ঘুরে দেখাকে বলে traversal. একটি অস্থায়ী pointer p নিয়ে head থেকে শুরু করুন, প্রতি ধাপে p = p->next, আর NULL-এ পৌঁছালে থামুন। খেয়াল করুন — head-কে নিজে কখনো সরাবেন না, সরাবেন শুধু p-কে। নিচের প্রোগ্রামটি তিনটি নম্বর দিয়ে তালিকা বানায়, ছাপে, তারপর প্রতিটি node free করে:

#include <stdio.h>
#include <stdlib.h>
struct Node { int marks; struct Node *next; };
int main(void) {
    struct Node *head = NULL;  int marks[] = {64, 85, 72};
    for (int i = 0; i < 3; i++) {        /* each new node goes to the front */
        struct Node *n = malloc(sizeof *n);
        if (n == NULL) break;            /* no memory: stop adding */
        n->marks = marks[i];
        n->next = head;                  /* 1: new node holds the old first node */
        head = n;                        /* 2: head moves to the new node */
    }
    for (struct Node *p = head; p != NULL; p = p->next)
        printf("%d -> ", p->marks);
    printf("NULL\n");
    while (head != NULL) {               /* free every node, one by one */
        struct Node *next = head->next;  /* save the address BEFORE free */
        free(head);
        head = next;
    }
    return 0;
}

আউটপুট আসবে 72 -> 85 -> 64 -> NULL — ঢোকানোর উল্টো ক্রমে, কারণ প্রতিটি নতুন node সবার সামনে বসেছে। malloc ব্যর্থ হলে লুপ থেমে যায়, কিন্তু যতগুলো node তৈরি হয়েছে সেগুলো ঠিকই তালিকায় থাকে আর শেষে free হয় — তাই কোনো অবস্থাতেই memory leak নেই। শেষের লুপে next আগে মনে রাখা হয়েছে, তারপর free — কেন এই ক্রম জরুরি, অংশ ৬-এ ধাপে ধাপে দেখব।

শেষে যোগ করা — tail pointer-এর কৌশল

শুরুতে যোগ করা সস্তা, কিন্তু অনেক সময় ক্রম ঠিক রাখতে শেষে যোগ করতে হয় (যেমন লাইনে দাঁড়ানো মানুষের তালিকা)। শুধু head থাকলে শেষ node খুঁজতে প্রতিবার পুরো তালিকা হাঁটতে হয় — ১০ লাখ node থাকলে ১০ লাখ ধাপ। সমাধান: শেষ node-এর ঠিকানাও আলাদা করে রেখে দিন — tail pointer. তখন শেষে যোগ করা হয় মাত্র দুই ধাপে: tail->next = n; tail = n; (খালি তালিকায় head আর tail দুটোই n হবে — এই edge case ভুললে চলবে না)।

৪ · মাঝে নতুন node ঢোকানো — দুটি লাইন, ক্রমটাই আসল

এবার অংশ ১-এর সেই সমস্যা: 55 → 64 → 85 তালিকায় 64-এর পরে 70 বসাতে হবে। ধরুন prev নামের একটি pointer আগে থেকেই ৬৪-এর node-টিকে দেখাচ্ছে। Array-তে পরের সবাইকে সরাতে হতো; এখানে কাউকে সরাতে হবে না — বদলাবে মাত্র দুটি pointer. কিন্তু এই দুটি লাইনের ক্রম উল্টে গেলে কী হয়, সেটিও নিচে দেখুন:

1struct Node *n = malloc(sizeof *n);2n->marks = 70;3n->next = prev->next;   /* আগে */4prev->next = n;         /* পরে */56/* ভুল ক্রম হলে: */7prev->next = n;8n->next = prev->next;
Pointer-গুলোর অবস্থা
prev
prev->next
n
n->next
85-এর ঠিকানা জানে
  1. শুরুর অবস্থা। তালিকা ৫৫ → ৬৪ → ৮৫. prev দেখাচ্ছে ৬৪-এর node-কে (ঠিকানা 0x2040), আর তার next-এ আছে ৮৫-এর ঠিকানা 0x3080. খেয়াল করুন নিচের সারিটি — ৮৫-এর ঠিকানা এখন শুধু একজনই জানে: prev->next.
  2. নতুন node তৈরি। malloc দিয়ে 0x6010 ঠিকানায় একটি node পাওয়া গেল, তাতে ৭০ বসানো হলো। তার next-এ এখনো আবর্জনা — malloc ঘর পরিষ্কার করে দেয় না। Node-টি এখনো তালিকার বাইরে একা ঝুলছে।
  3. লাইন ৩ — নতুন node আগে ৮৫-কে ধরল। n->next = prev->next. তালিকা এখনো বদলায়নি, কিন্তু এখন ৮৫-এর ঠিকানা দুজন জানে। এটিই নিরাপত্তার চাবি: যে হুক খুলব, তার আগে অন্য হাতে শিকলের বাকি অংশটা ধরে রাখা।
  4. লাইন ৪ — ৬৪ এবার নতুন node-কে দেখায়। prev->next = n. তালিকা হলো ৫৫ → ৬৪ → ৭০ → ৮৫. কাউকে সরাতে হয়নি, তালিকা ১০ node-এর হোক বা ১০ লাখের — কাজ সবসময় এই দুই লাইন। এটাই linked list-এর আসল শক্তি।
  5. ভুল ক্রম, লাইন ৭ — আগে prev বদলালে। ধরুন শুরু থেকে আবার, এবার আগে লিখলেন prev->next = n. সাথে সাথে ৮৫-এর ঠিকানা মুছে গেল — আর কেউ সেটি জানে না! ৮৫ ও তার পরের সব node এখন heap-এ পড়ে আছে, কিন্তু কোনো pointer তাদের দেখায় না। এটি নিশ্চিত memory leak.
  6. ভুল ক্রম, লাইন ৮ — ক্ষতি আরও বাড়ল। এবার n->next = prev->next — কিন্তু prev->next তো এখন n নিজেই! ফলে নতুন node নিজেকেই দেখাচ্ছে। Traversal চালালে ৭০, ৭০, ৭০… চিরকাল ছাপতে থাকবে। মনে রাখার সহজ নিয়ম: আগে নতুন node-কে শিকলে হাত দিতে দিন, তারপর পুরোনো হুক খুলুন।

একটি সূক্ষ্ম বিষয় খেয়াল করুন: এখানে ঢোকানো সস্তা, কারণ prev আগে থেকেই হাতে ছিল। কিন্তু “৬৪-এর পরে ঢোকাও” বললে আগে তো ৬৪-কে খুঁজতে হবে — আর সেটি করতে head থেকে একে একে হাঁটতে হয়। এই খোঁজার খরচই linked list-এর আসল দুর্বলতা, যা অংশ ৮-এ বিস্তারিত দেখব।

৫ · Node মুছে ফেলা — আগে জোড়া, পরে free

মুছে ফেলাও pointer জোড়া বদলানোর খেলা: যে node মুছব, তার আগের node-কে সরাসরি তার পরের node-এর সাথে জুড়ে দিন। তারপর মোছা node-টির মেমরি free করে heap-এ ফেরত দিন। কিন্তু এখানেও ক্রম ভুল হলে বিপদ — আর সেই বিপদটি চোখে পড়ে না, তাই আরও ভয়ংকর।

1struct Node *dead = prev->next;2prev->next = dead->next;   /* জোড়া */3free(dead);                /* ফেরত */45/* ভুল ক্রম হলে: */6free(dead);7prev->next = dead->next;   /* free-র পরে ছোঁয়া! */
Pointer ও heap-এর অবস্থা
prev (55)
prev->next
dead
block 0x2040
  1. শুরু। তালিকা ৫৫ → ৬৪ → ৮৫; ৬৪ মুছতে হবে। prev দেখাচ্ছে ৫৫-কে। মুছতে হলে আগের node-টি হাতে থাকতেই হবে — singly list-এ পেছনে ফেরার পথ নেই।
  2. লাইন ১ — যাকে মুছব, তাকে নাম দিন। dead = prev->next. এখন ৬৪-এর node-এর ঠিকানা আলাদা একটি ভ্যারিয়েবলে ধরা আছে। এটি না রাখলে পরের লাইনের পর তাকে আর খুঁজে পাওয়া যেত না, free করারও উপায় থাকত না।
  3. লাইন ২ — পাশ কাটিয়ে জোড়া। prev->next = dead->next — ৫৫ এবার সরাসরি ৮৫-কে দেখায়। তালিকার দিক থেকে ৬৪ আর নেই। কিন্তু তার মেমরি এখনো heap-এ আছে, আর dead সেটি চেনে।
  4. লাইন ৩ — এবার free. Block ফেরত গেল allocator-এর কাছে। dead-এ এখনো সেই পুরোনো ঠিকানা লেখা আছে — এখন সে একটি dangling pointer. এর পরে dead->marks বা dead->next পড়া সম্পূর্ণ নিষেধ।
  5. ভুল ক্রম — আগে free. শুরু থেকে আবার ভাবুন, এবার আগে free(dead). Block allocator-এর হাতে চলে গেল। অনেক allocator ফাঁকা block-এর ভেতরেই নিজের হিসাবের তথ্য লিখে রাখে — ঠিক যে জায়গায় আপনার next ছিল, সেখানেও।
  6. তারপর dead->next পড়া — use-after-free. এখন যে মান পাবেন, সেটি আসল ৮৫-এর ঠিকানা হতেও পারে, না-ও হতে পারে। কখনো প্রোগ্রাম ঠিকঠাক চলে (সবচেয়ে বিপজ্জনক — বাগ লুকিয়ে থাকে), কখনো crash করে। এটিই use-after-free. -fsanitize=address দিয়ে চালালে sanitizer এখানেই heap-use-after-free বলে থামিয়ে দেবে।

Head মুছতে হলে? — pointer to pointer-এর কৌশল

উপরের কোডে একটি ফাঁক আছে: প্রথম node (৫৫) মুছতে চাইলে তার কোনো prev নেই! তখন বদলাতে হবে head-কে নিজেই। সাধারণ সমাধানে দুটি আলাদা পথ লেখা হয় — “যদি head হয় তাহলে এটা, নইলে ওটা।” এখানেই AI ও নতুনরা সবচেয়ে বেশি ভুল করেন।

আরও সুন্দর একটি সমাধান আছে। মনে করুন head একটি pointer, আর প্রতিটি node-এর next-ও একটি pointer. মুছতে গিয়ে আমাদের আসলে বদলাতে হয় “যে pointer-টি এই node-কে দেখাচ্ছে” — সেটি head হোক বা কারো next. তাই আমরা হাতে রাখি সেই pointer-টির ঠিকানা, অর্থাৎ struct Node ** — pointer to pointer (পর্ব ১৭)। তখন head আর মাঝের node-এর জন্য একই কোড কাজ করে:

#include <stdio.h>
#include <stdlib.h>
struct Node { int val; struct Node *next; };
void delete_value(struct Node **pp, int target) {
    while (*pp != NULL && (*pp)->val != target)
        pp = &(*pp)->next;              /* move to the next "link box" */
    if (*pp == NULL) return;            /* not found: do nothing */
    struct Node *dead = *pp;
    *pp = dead->next;                   /* 1: rewire around it */
    free(dead);                         /* 2: only then free */
}
int main(void) {
    struct Node *head = NULL;
    for (int v = 40; v >= 10; v -= 10) {          /* builds 10 20 30 40 */
        struct Node *n = malloc(sizeof *n);
        if (n == NULL) break;
        n->val = v; n->next = head; head = n;
    }
    delete_value(&head, 10);  delete_value(&head, 30);   /* head, then middle */
    for (struct Node *p = head; p != NULL; p = p->next) printf("%d ", p->val);
    printf("\n");
    while (head != NULL) delete_value(&head, head->val);  /* free all */
    return 0;
}

আউটপুট 20 40. pp শুরুতে head-এর ঠিকানা ধরে আছে। ১০ মুছতে গেলে লুপ একবারও চলে না, তাই *pp = dead->next সরাসরি head-কেই বদলে দেয়। ৩০ মুছতে গেলে pp হেঁটে গিয়ে ২০-এর node-এর next ঘরের ঠিকানায় পৌঁছায়, আর একই লাইন সেটিকে বদলায়। কোনো if (head) নেই, তবু head, মাঝখান, শেষ আর “পাওয়া যায়নি” — চারটি ক্ষেত্রই ঠিক। শেষ লাইনে একই ফাংশন দিয়ে প্রতিবার প্রথম node মুছে পুরো তালিকা free করা হয়েছে। Linux-এর স্রষ্টা Linus Torvalds এক সাক্ষাৎকারে ঠিক এই কৌশলকে “good taste”-এর উদাহরণ হিসেবে দেখিয়েছিলেন — বিশেষ ক্ষেত্র আলাদা করে না সামলে এমনভাবে লেখা, যাতে বিশেষ ক্ষেত্রটাই আর না থাকে।

আরেকটি প্রচলিত উপায় হলো তালিকার শুরুতে একটি ফাঁকা পাহারাদার node রাখা — dummy node. তখন প্রতিটি আসল node-এর আগে সবসময় কেউ না কেউ থাকে, head-এর বিশেষ ক্ষেত্র উঠে যায়। Linux kernel-এর তালিকা ঠিক এভাবে বানানো (অংশ ৯)।

৬ · পুরো তালিকা free করা আর চারটি ক্লাসিক বাগ

প্রোগ্রাম শেষে, বা তালিকার আর দরকার না থাকলে, প্রতিটি node free করতে হবে। শুধু free(head) লিখলে প্রথম node ফেরত যায় — বাকি সব node heap-এ রয়ে যায়, অথচ তাদের ঠিকানা আর কারো কাছে নেই। নিরাপদ উপায়টি ধাপে ধাপে দেখুন:

1while (head != NULL) {2    struct Node *next = head->next;3    free(head);4    head = next;5}
লুপের অবস্থা
head
next
ফেরত দেওয়া node
  1. শুরু। তালিকায় তিনটি node. শর্ত যাচাই: head NULL নয়, তাই লুপের ভেতরে ঢুকছে।
  2. আগে পরের ঠিকানা মনে রাখা। next = head->next — ৬৪-এর ঠিকানা আলাদা ভ্যারিয়েবলে রাখা হলো। এই লাইনটিই পুরো কৌশলের প্রাণ।
  3. এবার free. ৫৫-এর block ফেরত গেল। head এখন dangling — এর ভেতরে আর হাত দেওয়া যাবে না। কিন্তু চিন্তা নেই, পরের node-এর ঠিকানা তো next-এ আছে।
  4. এগিয়ে যাওয়া। head = next — head এখন ৬৪-কে দেখায়। তালিকার বাকি অংশ নিরাপদ।
  5. একই কাজ আবার। next-এ রাখা হলো ৮৫-এর ঠিকানা, ৬৪ free হলো, head গেল ৮৫-এ। পরের পাকে next-এ আসবে NULL (৮৫-এর next).
  6. শেষ node. ৮৫ free হলো, head হলো NULL. লুপের শর্ত মিথ্যা — বেরিয়ে এলো।
  7. ফল: leak শূন্য, head নিজেই NULL. তুলনার জন্য প্রচলিত ভুলটি দেখুন: for (p = head; p != NULL; p = p->next) free(p); — এখানে free(p)-এর পরে p->next পড়া হয়, অর্থাৎ প্রতি ধাপে use-after-free. অনেক সময় এটি “কাজ করে”, তাই বাগটি বছরের পর বছর লুকিয়ে থাকতে পারে।

Linked list-এর চারটি ক্লাসিক বাগ

১. Head হারিয়ে ফেলা

Traversal করতে গিয়ে head = head->next লিখে ফেলা। লুপ শেষে head হয় NULL — পুরো তালিকা হারিয়ে গেছে, সব node leak. সবসময় আলাদা p দিয়ে হাঁটুন।

লক্ষণ: দ্বিতীয়বার ছাপলে কিছুই আসে না

২. Free-র পরে ছোঁয়া

মুছে ফেলার সময় আগে free, পরে dead->next পড়া; বা free লুপে p = p->next. ফল অনিশ্চিত — পর্ব ২০-এর use-after-free.

লক্ষণ: মাঝে মাঝে crash, ASan-এ heap-use-after-free

৩. শেষে NULL dereference

while (p->next != NULL) লিখলে খালি তালিকায় (p == NULL) প্রথম লাইনেই crash. p->next->val পড়ার আগে p->next NULL কি না দেখুন।

লক্ষণ: Segmentation fault

৪. আধাআধি free

শুধু free(head), বা মুছে ফেলা node-কে তালিকা থেকে খুলে free করতে ভুলে যাওয়া। প্রোগ্রাম চলে, কিন্তু মেমরি ধীরে ধীরে খেয়ে ফেলে।

লক্ষণ: Valgrind বলে “definitely lost”

তৃতীয় বাগটির মূল কারণ null pointer dereference — NULL ঠিকানার ভেতরে কিছু পড়তে যাওয়া। আধুনিক OS-এ এটি সাধারণত সাথে সাথে segmentation fault দেয়। এটি অন্তত চোখে পড়ে। কিন্তু দ্বিতীয় আর চতুর্থ বাগ নীরব। তাই list-এর কোড লিখে বা AI থেকে পেয়ে সবসময় -fsanitize=address অথবা Valgrind দিয়ে একবার চালিয়ে নিন।

৭ · দুই দিকের ও গোল তালিকা — doubly আর circular

Doubly linked list — পেছনের দিকেও চেনা

Singly list-এর একটি বড় অসুবিধা দেখেছেন: কোনো node মুছতে হলে তার আগের node লাগে, অথচ node নিজে জানে না তার আগে কে। সমাধান — প্রতিটি node-এ আরেকটি pointer যোগ করুন, prev. এই তালিকার নাম doubly linked list.

struct DNode {
    int val;
    struct DNode *prev, *next;
};

/* x-কে তালিকা থেকে খুলে ফেলা — আগের node খুঁজতে হয় না */
if (x->prev) x->prev->next = x->next; else head = x->next;
if (x->next) x->next->prev = x->prev;
free(x);

এখন হাতে শুধু node-টির ঠিকানা থাকলেই সাথে সাথে মুছে ফেলা যায় — খোঁজার দরকার নেই। দুই দিকেই হাঁটা যায়। দাম কী? প্রতি node-এ আরও একটি pointer (৬৪-বিটে ৮ byte বাড়তি), আর প্রতিটি ঢোকানো বা মুছে ফেলায় দুটির বদলে চারটি pointer ঠিক করতে হয় — ভুলের সুযোগও দ্বিগুণ। ব্রাউজারের Back/Forward বোতাম বা টেক্সট এডিটরের undo/redo-র ইতিহাস এই রকম দুই-দিকি তালিকার চেনা উদাহরণ।

Circular linked list — শেষ বলে কিছু নেই

শেষ node-এর next যদি NULL না হয়ে আবার প্রথম node-কে দেখায়, তাহলে পাওয়া যায় circular linked list. এখানে “শেষ” নেই — ঘুরে ঘুরে চলে।

🔁 Playlist on repeat

গানের অ্যাপে “Repeat all” চালু করলে শেষ গানের পর আবার প্রথম গান। Circular list-এ “পরের গান” সবসময় current->next — শেষে এসে আলাদা কোনো শর্ত লাগে না।

শেষের পর আবার শুরু

⏱️ Round-robin

অনেকগুলো কাজকে পালা করে একটু একটু সময় দেওয়ার নিয়ম — round-robin. ঈদের আগে দর্জির দোকানে কারিগর একেকজনের জামায় একটু একটু কাজ করে আবার প্রথম জনে ফেরেন। সরল CPU scheduler এভাবেই প্রসেসগুলোকে ঘুরিয়ে ঘুরিয়ে চালায়।

সবার জন্য সমান পালা

Circular list-এ while (p != NULL) লিখলে লুপ কখনো থামবে না — NULL তো কোথাও নেই! এখানে থামার শর্ত হলো “শুরুর node-এ ফিরে এসেছি কি না”, যা সাধারণত do { … p = p->next; } while (p != start); দিয়ে লেখা হয়। আর খালি তালিকায় start নিজেই NULL হতে পারে — সেটি আগে আলাদা করে দেখতে হবে।

৮ · খরচের হিসাব — কেন বাস্তবে array প্রায়ই জেতে

পাঠ্যবইয়ে প্রায়ই একটি সারণি থাকে, যা দেখে মনে হয় linked list দারুণ। সারণিটি ভুল নয়, কিন্তু অসম্পূর্ণ। এখানে time complexity-র ভাষায় লিখছি — O(1) মানে তালিকা যত বড়ই হোক সময় প্রায় একই, O(n) মানে সময় তালিকার আকারের সাথে বাড়ে (Big-O নিয়ে বিস্তারিত পর্ব ৩৯-এ)।

কাজArray / dynamic arrayLinked list
i-তম জিনিস পড়াO(1) — সরাসরি indexO(n) — head থেকে i ধাপ হাঁটা
শুরুতে যোগO(n) — সবাইকে সরাতে হয়O(1)
শেষে যোগসাধারণত O(1) (মাঝে মাঝে realloc)O(1) — tail pointer থাকলে
জানা জায়গার পরে ঢোকানো / মোছাO(n) — পরের সবাইকে সরাতে হয়O(1) — শুধু pointer বদল
মান দিয়ে খোঁজাO(n)O(n)
প্রতি জিনিসে বাড়তি মেমরিপ্রায় শূন্যএকটি-দুটি pointer + malloc-এর নিজস্ব হিসাব
CPU cache-এর সাথে বনিবনাচমৎকারখারাপ

শেষ সারিটিই সবচেয়ে গুরুত্বপূর্ণ, অথচ অনেক বইয়ে থাকে না। পর্ব ১৬-এ দেখেছেন, CPU মেমরি থেকে একটি একটি byte আনে না, আনে একবারে একটি পুরো cache line (প্রায়ই ৬৪ byte). পাশাপাশি থাকা ডেটা তাই বিনা খরচে চলে আসে — একে বলে spatial locality. Linked list-এর node-গুলো ছড়ানো, তাই এই সুবিধা প্রায় পাওয়াই যায় না। নিচে দুটির মেমরি পাশাপাশি দেখুন:

int marks[4] — arrayসবাই গায়ে গায়ে
ঠিকানা   0x1000  0x1004  0x1008  0x100C
        +-------+-------+-------+-------+
        |  72   |  85   |  64   |  90   |
        +-------+-------+-------+-------+
        |<------ একটি cache line (64 byte) ----->
        একবার RAM-এ গিয়েই চারজন একসাথে এলো
linked list — heap-এ ছড়ানোপ্রতিটি node আলাদা জায়গায়
head = 0x5a20

0x3c40: [ 64 | next=0x7e10 ]   <- লাফ ৩ (নতুন cache line)
0x5a20: [ 72 | next=0x91f0 ]   <- শুরু (cache miss)
0x7e10: [ 90 | next=NULL   ]   <- লাফ ৪ (নতুন cache line)
0x91f0: [ 85 | next=0x3c40 ]   <- লাফ ২ (নতুন cache line)
পথ: 0x5a20 → 0x91f0 → 0x3c40 → 0x7e10 → NULL
  1. Array — গায়ে গায়ে চার ঘর। চারটি int, প্রতিটি ৪ byte, ঠিকানা পরপর: 0x1000, 0x1004… পুরো array মাত্র ১৬ byte.
  2. একটি cache line-এ পুরো array. marks[0] পড়তে CPU RAM-এ গেল, আর ফিরল পুরো ৬৪ byte-এর টুকরো নিয়ে। বাকি তিনটি সংখ্যা এখন cache-এ — পড়তে প্রায় কোনো সময়ই লাগবে না। তার ওপর CPU ধরতে পারে আপনি পরপর পড়ছেন, তাই পরের টুকরোও আগেভাগে আনতে শুরু করে।
  3. Linked list — চারটি node, চার জায়গায়। malloc যেখানে জায়গা পেয়েছে সেখানেই দিয়েছে। প্রথম node 0x5a20-এ। সেখানে যেতেই একটি cache miss — RAM থেকে আনতে হলো।
  4. লাফ ২ — 0x91f0. পরের node কোথায়, তা জানা গেল কেবল আগের node পড়ার পরে। এটি একদম অন্য এলাকায়, তাই আবার RAM-এ যাত্রা। CPU আগে থেকে আনতেও পারে না, কারণ ঠিকানাটা আগে জানাই ছিল না।
  5. লাফ ৩ — 0x3c40. আবার নতুন জায়গা, আবার অপেক্ষা। ঠিকানার পিছু পিছু এই হাঁটাকে বলে pointer chasing. প্রতিটি পদক্ষেপ আগেরটির ওপর নির্ভর করে, তাই একসাথে কয়েকটি আনার উপায় নেই।
  6. লাফ ৪ — শেষ, তারপর NULL. একই চারটি সংখ্যা পড়তে array লেগেছে একবার RAM-এ যাওয়া, list লেগেছে চারবার — আর RAM থেকে আনা cache থেকে পড়ার চেয়ে বহু গুণ ধীর। node যত বেশি, পার্থক্য তত বড়। তার ওপর প্রতিটি node-এ ডেটা ৪ byte হলেও pointer ৮ byte, সাথে malloc-এর নিজস্ব হিসাব — ব্যবহারযোগ্য ডেটার চেয়ে বাড়তি জায়গাই বেশি।
তাহলে linked list কখন সত্যিই জেতে?

যখন (১) ঢোকানো-মোছার জায়গাটি আগে থেকেই হাতে আছে, খুঁজতে হয় না — যেমন LRU cache-এ hash table সরাসরি node দিয়ে দেয়; (২) জিনিসগুলো মেমরিতে নড়তে পারবে না, কারণ অন্যরা তাদের ঠিকানা ধরে রেখেছে (array realloc হলে সব ঠিকানা বদলে যায়, list-এর node কখনো সরে না); (৩) প্রতিটি কাজের সময় নিশ্চিতভাবে ছোট রাখতে হবে — realloc-এর মতো হঠাৎ একবার বড় কপি গ্রহণযোগ্য নয়, যেমন OS kernel বা real-time সিস্টেমে; (৪) একটি জিনিস একসাথে কয়েকটি তালিকায় থাকবে। বাকি বেশিরভাগ ক্ষেত্রে — বিশেষ করে শুধু “একটি তালিকায় জিনিস রাখা আর ঘুরে দেখা” — dynamic array-ই দ্রুত ও সহজ।

৯ · বাস্তব সিস্টেমে linked list — kernel, cache, malloc

উপরের হিসাব দেখে মনে হতে পারে linked list বুঝি শুধু পরীক্ষার প্রশ্ন। মোটেই না। পৃথিবীর সবচেয়ে গুরুত্বপূর্ণ কিছু সফটওয়্যারের ভেতরে linked list কাজ করছে — শুধু ঠিক সেই জায়গাগুলোতে, যেখানে তার শক্তি কাজে লাগে।

Linux kernel-এর list_head — ডেটার ভেতরেই হুক

আমরা এতক্ষণ যে list বানিয়েছি, সেখানে node-এর ভেতরে ডেটা থাকে। Linux kernel উল্টোটা করে: ডেটার struct-এর ভেতরে একটি ছোট “হুক” বসিয়ে দেয়। include/linux/list.h ফাইলে সংজ্ঞাটি প্রায় এমন:

struct list_head { struct list_head *next, *prev; };

struct task {                 /* উদাহরণ: একটি প্রসেসের তথ্য */
    int pid;
    struct list_head run_list;   /* এই হুক দিয়ে "চলার অপেক্ষায়" তালিকায় */
    struct list_head all_list;   /* আর এটি দিয়ে "সব প্রসেস" তালিকায় */
};

এ ধরনের তালিকাকে বলে intrusive list — তালিকার pointer ডেটার ভেতরে “ঢুকে” থাকে। লাভ তিনটি: আলাদা node-এর জন্য বাড়তি malloc লাগে না; একটি struct একসাথে কয়েকটি তালিকায় থাকতে পারে (প্রতিটির জন্য একটি করে হুক); আর হুকের ঠিকানা হাতে থাকলে container_of macro দিয়ে পুরো struct-এর ঠিকানা বের করা যায় — হুকের ঠিকানা থেকে offsetof বিয়োগ করে। Kernel-এর তালিকাগুলো circular ও doubly, আর শুরুতে থাকে একটি ফাঁকা list_head — অর্থাৎ অংশ ৫-এর dummy node. তাই সেখানে খালি তালিকা বা head-এর আলাদা কোনো বিশেষ ক্ষেত্রই নেই।

LRU cache — hash table আর doubly list-এর জুটি

ফোনের গ্যালারি অ্যাপ সব ছবির ছোট সংস্করণ (thumbnail) মেমরিতে রাখতে পারে না। জায়গা ফুরালে কোনটি ফেলবে? যুক্তিসংগত উত্তর: যেটি সবচেয়ে বেশি সময় কেউ দেখেনি। এই নিয়মে চলা cache-কে বলে LRU cache (Least Recently Used). দুটি কাঠামো মিলিয়ে বানানো হয়:

  • একটি hash table — key দিলে সরাসরি সেই জিনিসের node-এর ঠিকানা দেয় (খুঁজতে হাঁটতে হয় না)।
  • একটি doubly linked list — সামনে সবচেয়ে সাম্প্রতিক, পেছনে সবচেয়ে পুরোনো।

কেউ একটি জিনিস চাইলে hash table node দেয়; doubly list বলে সেটিকে O(1)-এ খুলে সামনে নেওয়া যায়। জায়গা ফুরালে পেছনের node ফেলে দেওয়া হয়, এটিও O(1). এখানে linked list-এর দুর্বলতা (খোঁজা) hash table ঢেকে দিয়েছে, আর তার শক্তি (জানা জায়গায় সস্তায় সরানো) পুরো কাজে লাগছে। Memcached-এর মতো cache সার্ভার আর অনেক ডেটাবেস ইঞ্জিন এই ধরনের কাঠামো ব্যবহার করে। চাকরির ইন্টারভিউতেও “একটি LRU cache বানান” খুব পরিচিত প্রশ্ন।

malloc-এর ভেতরের free list

আপনি যখন free(p) করেন, মেমরি সাধারণত সাথে সাথে OS-এ ফেরত যায় না। Allocator সেই ফাঁকা block-গুলো নিজের কাছে একটি তালিকায় জমিয়ে রাখে — free list. মজার ব্যাপার: এই তালিকার next pointer আলাদা কোথাও থাকে না, থাকে ফাঁকা block-গুলোর নিজের ভেতরেই (block তো এখন ফাঁকা, জায়গাটা কাজে লাগানো যায়)। পরের malloc এলে allocator আগে এই তালিকা থেকে মাপমতো block খোঁজে। এ কারণেই অংশ ৫-এ বলেছিলাম — free করা node-এর next ঘরে allocator নিজের তথ্য লিখে ফেলতে পারে। use-after-free কেন এত অদ্ভুত আচরণ করে, এবার পুরো ছবিটা পরিষ্কার।

১০ · AI-এর যুগে linked list কোড যাচাই

“C-তে একটি linked list লিখে দাও” — AI কয়েক সেকেন্ডে লিখে দেবে, আর প্রথম দেখায় সেটি ঠিকই মনে হবে। সাধারণ ইনপুটে চালালে চলবেও। সমস্যা লুকিয়ে থাকে ঠিক সেই জায়গাগুলোতে, যেগুলো সাধারণ পরীক্ষায় ধরা পড়ে না: খালি তালিকা, এক-node তালিকা, প্রথম node, শেষ node.

Linked list-এর বাগ থাকে কিনারায়।
খালি · এক node · head · tail — চারটিই নিজে পরীক্ষা করুন।

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

AI-এর ভুলকীভাবে ধরা পড়ে
Delete ফাংশন struct Node *head নেয় (কপি), তাই head মুছলে আসল head বদলায় নাপ্রথম node মুছে তালিকা ছাপুন — মুছে দেওয়া node-ই আবার আসে বা crash
শুধু free(head) বা মুছে ফেলা node free করতে ভুলে যায়Valgrind / ASan-এ “definitely lost”
Free লুপে free(p)-এর পরে p->nextASan-এ heap-use-after-free
head->next পড়ে শুরু করে, খালি তালিকা ভাবেনিখালি তালিকায় চালালে Segmentation fault
Tail pointer রাখে, কিন্তু শেষ node মুছলে tail আপডেট করে নাশেষ node মুছে নতুন একটি শেষে যোগ করুন
malloc-এর ফল NULL কি না দেখে নাকোড পড়েই ধরা যায়

AI-কে কোড লেখাতে বললে শুরুতেই কিনারার ক্ষেত্রগুলো বলে দিন, আর পরীক্ষাও চেয়ে নিন। তাহলে AI-কে নিজের কোড নিজে চাপে ফেলতে হয়:

C-তে একটি singly linked list লেখো, যেখানে int মান থাকবে। লাগবে: push_front, push_back, delete_value আর free_list. শর্ত: ১. head বদলাতে পারে এমন সব ফাংশন struct Node ** নেবে। ২. প্রতিটি malloc-এর ফল NULL কি না দেখবে। ৩. free_list-এ free করার আগে next মনে রাখবে। ৪. gcc -Wall -Wextra -fsanitize=address দিয়ে warning ও error ছাড়া চলতে হবে। শেষে একটি main দাও যা এই ক্ষেত্রগুলো পরীক্ষা করে: খালি তালিকা থেকে delete, এক-node তালিকা থেকে সেই node delete, head delete, শেষ node delete, তালিকায় নেই এমন মান delete. প্রতিটির পর তালিকা ছাপবে।

AI-এর লেখা list কোড হাতে পেলে — যাচাইয়ের চেকলিস্ট

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

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।

প্র ০১বই বলে মাঝে ঢোকানোয় linked list O(1), array O(n). তবু আধুনিক কম্পিউটারে array প্রায়ই জেতে কেন?

কারণ Big-O গোনে কতগুলো ধাপ, কিন্তু বলে না প্রতিটি ধাপের দাম কত। আর আধুনিক CPU-তে ধাপের দাম নির্ভর করে ডেটা কোথায় আছে তার ওপর।

  • খোঁজার খরচ লুকানো: “জানা জায়গায় ঢোকানো O(1)” — কিন্তু বাস্তবে “৫০০তম জায়গায় ঢোকাও” বললে আগে ৫০০টি node হাঁটতে হয়। তাই দুই ক্ষেত্রেই মোট কাজ আসলে O(n). পার্থক্য শুধু O(n) কাজটা কেমন: array-তে পাশাপাশি থাকা ডেটা সরানো, list-এ এলোমেলো ঠিকানায় লাফানো।
  • সরানো খুব সস্তা, লাফানো খুব দামি: array-র ঘর সরানো হয় পরপর মেমরিতে। CPU একবারে পুরো cache line আনে, পরের টুকরোও আগেভাগে আনে (prefetch), আর memmove-এর মতো ফাংশন একসাথে অনেক byte কপি করতে পারে। অন্যদিকে list-এর প্রতিটি লাফে cache miss হতে পারে, আর RAM থেকে আনতে cache-এর তুলনায় বহু গুণ সময় লাগে। পরের ঠিকানা আগের node না পড়ে জানা যায় না, তাই CPU অপেক্ষা ছাড়া কিছু করতে পারে না।
  • বাড়তি মেমরি: একটি int রাখতে list-এ লাগে pointer, padding আর malloc-এর হিসাব — প্রায়ই ডেটার কয়েক গুণ। বেশি মেমরি মানে cache-এ কম জিনিস ধরে।

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

তবে এর মানে এই নয় যে list অকেজো। যখন node-এর ঠিকানা অন্য কোথাও থেকে আগেই জানা (LRU-র hash table, kernel-এর হুক), তখন খোঁজার খরচ লাগে না — সেখানে list-ই সেরা।

মূল উপলব্ধি: Big-O একটি মানচিত্র, পুরো ভূখণ্ড নয়। আসল গতি জানতে হার্ডওয়্যার কীভাবে কাজ করে সেটি বুঝুন, আর অনুমান না করে মেপে দেখুন।

প্র ০২Intrusive আর non-intrusive list-এর পার্থক্য কী? Linux kernel কেন intrusive পথ বেছে নিয়েছে?

Non-intrusive (এই পর্বে আমরা যা বানিয়েছি, আর C++-এর std::list বা Java-র LinkedList): তালিকা নিজের node বানায়, আর ডেটা থাকে node-এর ভেতরে বা node ডেটার দিকে একটি pointer রাখে। ডেটা জানেই না সে কোনো তালিকায় আছে।

Intrusive (Linux-এর list_head): ডেটার struct-এর ভেতরেই next/prev হুক বসানো। তালিকা আলাদা কোনো node বানায় না।

  • বাড়তি malloc নেই: non-intrusive তালিকায় একটি জিনিস যোগ করতে আলাদা node-এর জন্য malloc লাগে — সেটি ব্যর্থও হতে পারে। Kernel-এর অনেক জায়গায় (যেমন interrupt সামলানোর সময়) মেমরি চাওয়াই ঝুঁকিপূর্ণ বা নিষেধ। Intrusive হলে হুক আগে থেকেই struct-এর ভেতরে আছে, যোগ করা কখনো ব্যর্থ হয় না।
  • একটি বস্তু, অনেক তালিকা: একটি প্রসেস একসাথে “সব প্রসেস”, “এই ব্যবহারকারীর প্রসেস”, “অপেক্ষায় থাকা প্রসেস” — কয়েকটি তালিকায় থাকতে পারে, প্রতিটির জন্য একটি হুক।
  • O(1)-এ খুলে ফেলা: বস্তুর ঠিকানা হাতে থাকলেই তার হুক হাতে, আর doubly হওয়ায় খোঁজা ছাড়াই তালিকা থেকে খুলে ফেলা যায়।
  • এক লাফ কম: ডেটা আর হুক একই জায়গায়, তাই node থেকে ডেটায় আলাদা করে লাফাতে হয় না — cache-এর জন্যও ভালো।

দাম কী? ডেটার struct-কে আগে থেকেই জানতে হয় সে কোন কোন তালিকায় থাকবে। আর container_of-এর মতো ঠিকানার হিসাব ভুল হলে compiler ধরতে পারে না, তাই টাইপ-নিরাপত্তা কম। Kernel-এর মতো জায়গায়, যেখানে গতি ও নির্ভরযোগ্যতা সবার আগে, এই দাম দেওয়া সার্থক। Boost.Intrusive-এর মতো C++ library-ও এই চিন্তা থেকেই এসেছে।

মূল উপলব্ধি: একই ধারণা (linked list) কোথায় pointer রাখা হচ্ছে তার ওপর নির্ভর করে একদম ভিন্ন বৈশিষ্ট্য পায়। ডেটা-স্ট্রাকচার ডিজাইন মানে শুধু “কোনটা” নয়, “মেমরিতে ঠিক কীভাবে” — এটাও।

প্র ০৩LRU cache-এ hash table আর doubly linked list কীভাবে একে অপরের ঘাটতি পূরণ করে? শুধু একটি দিয়ে হতো না কেন?

LRU cache-কে দুটি কাজ দ্রুত করতে হয়: (ক) key দিয়ে জিনিসটি খুঁজে পাওয়া, (খ) জিনিসগুলোর “সর্বশেষ কবে ব্যবহার হলো” ক্রম রাখা — যাতে ব্যবহার হলেই সেটিকে সামনে আনা যায়, আর জায়গা ফুরালে সবচেয়ে পুরোনোটি ফেলা যায়।

  • শুধু hash table: (ক) দারুণ দ্রুত, কিন্তু ভেতরে কোনো ক্রম নেই। সবচেয়ে পুরোনোটি খুঁজতে পুরো টেবিল ঘাঁটতে হবে — O(n).
  • শুধু linked list: ক্রম রাখা সহজ, কিন্তু key দিয়ে খুঁজতে হাঁটতে হবে — O(n).
  • শুধু array: ক্রম রাখা যায়, কিন্তু মাঝ থেকে একটিকে সামনে আনতে বাকিদের সরাতে হয় — O(n).

জুটি বাঁধলে: hash table-এ key-এর বিপরীতে রাখা হয় list-এর node-এর ঠিকানা। get(key) এলে hash table সরাসরি node দেয়; node-টিকে তালিকা থেকে খুলে সামনে বসাতে doubly list-এ লাগে মাত্র কয়েকটি pointer বদল — আগের node খুঁজতে হয় না, কারণ prev আছে। নতুন জিনিস ঢোকাতে গিয়ে জায়গা না থাকলে tail-এর node ফেলে দিন আর hash table থেকেও তার key মুছুন। প্রতিটি কাজ গড়ে O(1).

লক্ষ করুন, এখানে singly list কাজ করত না — মাঝের node খুলতে আগের node লাগে, আর সেটি খুঁজতে আবার O(n). আর node-এর ঠিকানা কখনো বদলায় না বলেই hash table নিশ্চিন্তে সেটি ধরে রাখতে পারে — dynamic array হলে realloc-এ সব ঠিকানা বদলে যেত।

বাস্তবে এর নানা রূপ দেখা যায়: Memcached-এর মতো cache সার্ভার, ডেটাবেসের buffer pool, CDN, এমনকি OS-এর page cache-ও LRU-ঘেঁষা নিয়ম ব্যবহার করে (প্রায়ই সরাসরি LRU-র বদলে আরও সস্তা কাছাকাছি কোনো পদ্ধতি)।

মূল উপলব্ধি: প্রতিটি ডেটা স্ট্রাকচার কিছু কাজে ভালো, কিছুতে খারাপ। বড় সিস্টেম প্রায়ই দুটি কাঠামো জুড়ে বানানো হয়, যাতে একটির দুর্বলতা অন্যটি ঢেকে দেয়। এই “জোড়া লাগানোর” চোখটাই সিস্টেম ডিজাইনের আসল দক্ষতা।

প্র ০৪বাস্তবে array বেশি চললেও linked list-এর প্রশ্ন চাকরির ইন্টারভিউতে এত জনপ্রিয় কেন?

কারণ ইন্টারভিউয়ার আসলে linked list জানতে চান না — তিনি দেখতে চান আপনি কয়েকটি গুরুত্বপূর্ণ দক্ষতা একসাথে দেখাতে পারেন কি না। আর ছোট একটি list-সমস্যা সেগুলো ১৫-২০ মিনিটে ফুটিয়ে তোলে।

  • Pointer-এর ওপর দখল: “তালিকাটি উল্টে দিন” — তিনটি pointer (prev, cur, next) ঠিক ক্রমে বদলাতে হয়। একটি ক্রম ভুল হলেই তালিকা ছিঁড়ে যায়। এটি মাথার ভেতরে মেমরির ছবি ধরে রাখার পরীক্ষা।
  • কিনারার ক্ষেত্রে মনোযোগ: খালি তালিকা, এক node, head, tail — এগুলো নিজে থেকে না বললে প্রার্থী ধরেন কি না, সেটাই অনেক কিছু বলে দেয়। বাস্তবের বেশিরভাগ বাগও ঠিক এই কিনারাতেই।
  • অপরিবর্তনীয় নিয়ম (invariant) নিয়ে চিন্তা: প্রতিটি ধাপের পর “head প্রথম node-কে দেখায়, শেষটির next NULL” — এই নিয়ম সত্য আছে কি না যাচাই করা।
  • চতুর কৌশল: তালিকায় লুপ আছে কি না বের করার Floyd-এর “খরগোশ ও কচ্ছপ” পদ্ধতি — একটি pointer এক ধাপ, আরেকটি দুই ধাপ করে চলে; লুপ থাকলে কোনো একসময় দুজন মিলবে। কোনো বাড়তি মেমরি ছাড়াই সমস্যার সমাধান — অংশ ৪-এর ভুল ক্রমের অসীম লুপ ঠিক এভাবে ধরা যায়।
  • মেমরি-ব্যবস্থাপনা: C/C++ ভূমিকায় node free হলো কি না, use-after-free হলো কি না — বাস্তব সিস্টেম কোডে এটাই নিরাপত্তার প্রশ্ন।

সমালোচনাও আছে: অনেকে বলেন এই প্রশ্ন মুখস্থ করা যায়, আর দৈনন্দিন কাজের সাথে মেলে না। তাই ভালো ইন্টারভিউয়াররা উত্তরের চেয়ে চিন্তার প্রক্রিয়া বেশি দেখেন — আপনি বাক্স-তীর এঁকে ভাবেন কি না, পরীক্ষার ক্ষেত্র নিজে প্রস্তাব করেন কি না। AI-এর যুগে এই দক্ষতা আরও দামি — কারণ AI-এর কোডে কিনারার বাগ ধরার কাজটাই এখন মানুষের।

মূল উপলব্ধি: Linked list হলো pointer-চিন্তার জিমনেসিয়াম। এখানে যে পেশি তৈরি হয় — ক্রম, কিনারা, invariant — সেটি tree, graph, এমনকি বড় সিস্টেম ডিজাইনেও কাজে লাগে।

প্র ০৫Haskell বা Clojure-এর মতো functional ভাষায় list কখনো বদলানো হয় না — তবু সেগুলো কাজ করে কীভাবে, আর তাতে লাভ কী?

এই পর্বে আমরা সবসময় তালিকা বদলেছি — pointer নতুন করে বসিয়েছি, node free করেছি। Functional ভাষাগুলো উল্টো নীতিতে চলে: একবার তৈরি হলে ডেটা আর বদলায় না (immutable).

তাহলে শুরুতে নতুন জিনিস যোগ করবেন কীভাবে? সহজ — একটি নতুন node বানান, যার next পুরোনো তালিকার head. পুরোনো তালিকা যেমন ছিল তেমনই থাকে, নতুন তালিকা তার পুরো অংশটা ভাগ করে নেয়। ধরুন A = [3, 4, 5]; তাহলে B = 2 : A আর C = 1 : A — তিনটি তালিকা, কিন্তু 3, 4, 5-এর node মেমরিতে একবারই আছে। একে বলে structural sharing, আর এমন কাঠামোকে বলে persistent data structure. Lisp-এর “cons cell” ১৯৫০-এর দশক থেকেই ঠিক এই জিনিস।

  • লাভ ১ — নিরাপদ ভাগাভাগি: কেউ বদলাতে পারে না, তাই একাধিক thread একসাথে পড়লেও lock লাগে না, race condition-এর ভয় নেই।
  • লাভ ২ — বিনা খরচে ইতিহাস: প্রতিটি পুরোনো সংস্করণ অক্ষত থাকে। Undo, “সময়ে ফিরে যাওয়া” debugging, আর git-এর commit-ইতিহাসও একই চিন্তায় চলে — নতুন commit পুরোনোকে বদলায় না, শুধু তাকে দেখায়।
  • লাভ ৩ — বোঝা সহজ: ফাংশনে তালিকা পাঠালে সেটি নষ্ট হবে, এই ভয় নেই।

দাম কী? মাঝের কিছু বদলাতে হলে শুরু থেকে সেই পর্যন্ত সব node নতুন করে কপি করতে হয়। পুরোনো সংস্করণ কখন আর কেউ দেখছে না, তা জানতে garbage collection লাগে — C-তে হাতে free করে এটা সামলানো খুব কঠিন। আর cache-এর সমস্যা তো আছেই। তাই Clojure-এর মতো ভাষা বড় সংগ্রহের জন্য সরল list-এর বদলে চওড়া শাখার tree ব্যবহার করে, যাতে একসাথে অনেক ডেটা পাশাপাশি থাকে।

মূল উপলব্ধি: “বদলাবো, নাকি নতুন বানাবো” — এটি একটি মৌলিক ডিজাইন সিদ্ধান্ত। C আপনাকে বদলানোর পূর্ণ ক্ষমতা দেয়, সাথে সব দায়ও। অন্য ভাষাগুলো সেই ক্ষমতা কিছুটা ছেড়ে দিয়ে নিরাপত্তা কেনে। দুটির trade-off বুঝলে যেকোনো ভাষায় ভালো ডিজাইন করতে পারবেন।

অনুশীলন

খাতা-কলম নিন। বাক্স আর তীর এঁকে ভাবাই এখানে আসল কাজ — কোড লাগবে শুধু শেষটিতে।

  1. কাগজে তালিকা: খালি তালিকা থেকে শুরু করে ক্রমানুসারে শুরুতে যোগ করুন ১০, ২০, ৩০; তারপর শেষে যোগ করুন ৪০। প্রতিটি ধাপের পর বাক্স-তীর এঁকে head ও tail কোথায় আছে লিখুন। শেষে তালিকাটি কেমন দাঁড়াল?

    শুরুতে ১০: head → 10 → NULL, tail = ১০। শুরুতে ২০: 20 → 10, tail বদলায়নি (এখনো ১০)। শুরুতে ৩০: 30 → 20 → 10. শেষে ৪০: tail->next = n; tail = n; → 30 → 20 → 10 → 40 → NULL, tail = ৪০। খেয়াল করুন, প্রথম যোগের সময় head আর tail দুটোই একই node-কে দেখাচ্ছিল — খালি তালিকার এই বিশেষ ক্ষেত্রটি tail-ওয়ালা কোডে আলাদা করে সামলাতে হয়।

  2. বাগ শিকারি: নিচের traversal কোডে কী ভুল? void print(struct Node *head) { while (head->next != NULL) { printf("%d ", head->val); head = head->next; } }

    দুটি ভুল। (১) খালি তালিকায় head NULL, তাই head->next পড়তেই Segmentation fault. (২) শর্তটি head->next != NULL হওয়ায় শেষ node-এর কাছে এসে লুপ থেমে যায় — শেষ মানটি ছাপাই হয় না। ঠিক শর্ত: while (head != NULL). মজার ব্যাপার: এখানে head = head->next লেখায় কোনো ক্ষতি নেই, কারণ head একটি কপি (pass by value) — আসল head বাইরে অক্ষত। কিন্তু একই লাইন main-এর ভেতরে লিখলে তালিকা হারিয়ে যেত।

  3. Array নাকি list: নিচের তিনটি ক্ষেত্রে কোনটি বেছে নেবেন, এক লাইনে কারণসহ বলুন — (ক) ৩০ লাখ ভোটারের তালিকা, শুধু শুরু থেকে শেষ পর্যন্ত বারবার পড়া হবে (খ) মেট্রো রেলের টিকিট কাউন্টারে লাইনে কে আগে এসেছে তার হিসাব, সামনে থেকে বের হয় আর পেছনে যোগ হয় (গ) একটি গেমে কয়েক হাজার শত্রু, যেকোনো সময় যেকোনোটি মারা পড়ে, আর অন্য অনেক অংশ তাদের ঠিকানা ধরে রাখে।

    (ক) Array. শুধু পরপর পড়া — cache-এর পূর্ণ সুবিধা, বাড়তি pointer-এর জায়গাও বাঁচে। (খ) দুটোই চলে — tail-ওয়ালা singly list সামনে থেকে বের করা আর পেছনে যোগ দুটোই O(1)-এ করে; তবে একটি circular array (ring buffer) প্রায়ই আরও দ্রুত। এটাই পরের পর্বের queue! (গ) Linked list (বিশেষ করে doubly বা intrusive) — শত্রুর ঠিকানা অন্যরা ধরে রেখেছে, তাই array realloc করে বস্তু সরানো চলবে না; আর মারা পড়লে নিজের ঠিকানা থেকেই O(1)-এ খুলে ফেলা যায়। (বাস্তব গেম ইঞ্জিন অনেক সময় এখানে array-র সাথে index-ভিত্তিক “free list” জাতীয় কৌশলও ব্যবহার করে।)

  4. নিজে লিখুন (একটিমাত্র কোডের কাজ): উপরের marks_list.c-এর সাথে একটি ফাংশন যোগ করুন int count(const struct Node *head), যা node-এর সংখ্যা ফেরত দেয়। খালি তালিকা ও তিন-node তালিকায় পরীক্ষা করুন।

    একটি সম্ভাব্য সমাধান:

    int count(const struct Node *head) {
        int n = 0;
        for (const struct Node *p = head; p != NULL; p = p->next)
            n++;
        return n;
    }

    খালি তালিকায় লুপ একবারও চলে না, ফল আসে 0 — আলাদা কোনো if লাগে না। const লিখে বোঝানো হলো ফাংশনটি তালিকা বদলাবে না; ভুল করে p->marks = 0 লিখলে compiler আপত্তি করবে।

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

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

ক্রমমাঝের একটি node নিরাপদে মুছে ফেলার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. 🔍 head থেকে হেঁটে মুছে ফেলার node-এর আগের node (prev) খুঁজে বের করা
  2. 📌 dead = prev->next — মুছে ফেলার node-এর ঠিকানা ধরে রাখা
  3. 🔗 prev->next = dead->next — পাশ কাটিয়ে জোড়া
  4. 🗑️ free(dead) — মেমরি ফেরত দেওয়া
  5. 🚫 এরপর dead-কে আর না ছোঁয়া

একটি singly linked list-এর শেষ node-এর next-এ কী থাকে?

  • প্রথম node-এর ঠিকানা
  • NULL
  • নিজের ঠিকানা
  • আবর্জনা মান — কিছু বসানো হয় না

prev-এর পরে নতুন node n ঢোকাতে কোন ক্রমটি ঠিক?

  • prev->next = n; তারপর n->next = prev->next;
  • n->next = n; তারপর prev->next = n;
  • n->next = prev->next; তারপর prev->next = n;
  • যেকোনো ক্রমে লিখলেই চলবে

এই কোডে কী সমস্যা? for (p = head; p != NULL; p = p->next) free(p);

  • প্রতি ধাপে free করা node-এর next পড়া হয় — use-after-free
  • শুধু প্রথম node free হয়, বাকিগুলো leak
  • খালি তালিকায় সাথে সাথে crash
  • কোনো সমস্যা নেই

মুছে ফেলার ফাংশন struct Node ** (pointer to pointer) নেয় কেন?

  • এতে কোড দ্রুত চলে
  • free() শুধু ** নিতে পারে বলে
  • Node-এর মেমরি কম লাগে বলে
  • যাতে দরকার হলে caller-এর head-ও বদলানো যায়

১০ লাখ সংখ্যা শুধু শুরু থেকে শেষ পর্যন্ত একবার যোগ করতে হবে। array সাধারণত linked list-এর চেয়ে দ্রুত কেন?

  • array-তে যোগ করার সংখ্যা কম লাগে
  • array-র ঘর পাশাপাশি, তাই cache কাজে লাগে; list-এর node ছড়ানো
  • compiler শুধু array-কে optimize করতে পারে
  • linked list হার্ডডিস্কে থাকে

LRU cache-এ doubly linked list ব্যবহারের মূল কারণ কোনটি?

  • key দিয়ে দ্রুত খোঁজার জন্য
  • মেমরি বাঁচানোর জন্য
  • জানা node-কে O(1)-এ খুলে সামনে আনা আর সবচেয়ে পুরোনোটি ফেলার জন্য
  • index দিয়ে সরাসরি যেকোনো জিনিস পড়ার জন্য

শব্দকোষ

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

মনে রাখুন

  1. Linked list = heap-এ ছড়ানো node, প্রতিটিতে ডেটা + next; শুরুতে head, শেষে NULL.
  2. ঢোকানো: আগে নতুন node-কে শিকল ধরান (n->next = prev->next), তারপর হুক বদলান (prev->next = n).
  3. মুছে ফেলা: আগে পাশ কাটিয়ে জোড়া, তারপর free, তারপর আর না ছোঁয়া; head বদলাতে পারে এমন ফাংশন নেয় struct Node **.
  4. জানা জায়গায় ঢোকানো-মোছা সস্তা, কিন্তু খোঁজা O(n), random access নেই, আর cache-এর কারণে বাস্তবে array প্রায়ই দ্রুত।
  5. যাচাই সবসময় চার কিনারায়: খালি · এক node · head · tail — সাথে ASan বা Valgrind.

পরের পর্বে: আজ যে linked list আর array বানালেন, তা দিয়েই তৈরি হবে দুটি খুব কাজের কাঠামো — stack (শেষে ঢুকলে আগে বেরোয়, যেমন থালার স্তূপ) আর queue (আগে এলে আগে, যেমন টিকিটের লাইন)। কোনটি কখন array দিয়ে আর কখন list দিয়ে বানানো ভালো, সেটিও দেখব।

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

  • পর্ব ৩৪ · স্ট্যাক ও কিউপরবর্তী পর্বArray ও linked list দিয়ে LIFO আর FIFO কাঠামো বানানো।
  • C প্রোগ্রামিং · কোর্স হোম৫৬টি পর্বপুরো কোর্সের রোডম্যাপ — কোন পর্ব কখন।
  • Data Structures & Algorithmsগভীরে যানList উল্টানো, লুপ খোঁজা, merge-সহ ক্লাসিক সমস্যা আর অ্যালগরিদমের পূর্ণ বিশ্লেষণ।
  • Operating Systemsপাশের ট্র্যাকScheduler-এর প্রসেস-তালিকা, page cache আর kernel-এর ভেতরের ডেটা স্ট্রাকচার।
  • Computer Architecture & Digital Logicপাশের ট্র্যাকCache, cache line আর prefetch — কেন মেমরির বিন্যাস গতি ঠিক করে।
এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান