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

Treap আর skip list — এলোমেলোতার সাহায্যে ভারসাম্য

Treaps and Skip Lists — Random Priorities, Rotations, Split & Merge, Implicit Treaps, Coin-Flip Levels and Expected O(log n)

Taught in Bangla, this lesson covers randomized balanced structures: treaps that are BSTs by key and heaps by random priority, why expected depth is O(log n) as a random BST, split and merge as the core operations, subtree sizes and implicit treaps for sequences, skip lists built from coin-flip levels, real uses in Redis and LevelDB, fixed seeds for testing, and comparisons with other balanced trees, with C++ and Python code.

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

  • কড়া ভারসাম্যের নিয়মের বিকল্প হিসেবে randomization কেন কাজ করে, আর “প্রত্যাশিত” কথাটির ঠিক অর্থ কী
  • Treap-এ insert আর delete rotation দিয়ে হাতে trace করা, আর প্রত্যাশিত গভীরতা O(log n)-এর প্রমাণ লেখা
  • Split ও merge দিয়ে পুরো treap লেখা, subtree-র আকার দিয়ে k-তম ছোট key বের করা, আর implicit treap দিয়ে sequence-এর অংশ উল্টানো
  • Skip list-এর তলা মুদ্রা দিয়ে বানানো, খোঁজার পথ trace করা, আর প্রত্যাশিত O(log n) সময় ও O(n) জায়গার যুক্তি দেওয়া
  • AVL, red-black, B-tree, splay, treap আর skip list-এর তুলনা করে সমস্যা অনুযায়ী বেছে নেওয়া, আর randomized কোডে নির্দিষ্ট seed রাখা

১ · সমস্যা — নিয়মের ভারসাম্য বনাম ভাগ্যের ভারসাম্য

পর্ব ২৭-এ দেখেছেন, সাধারণ binary search tree (BST) খুব সুন্দর, যতক্ষণ key-গুলো এলোমেলো ক্রমে আসে। কিন্তু সাজানো ক্রমে এলে — 1, 2, 3, … — tree একটি লম্বা শিকল হয়ে যায়, উচ্চতা n, আর প্রতিটি খোঁজা O(n). বাস্তবে সাজানো বা প্রায়-সাজানো ডেটা খুবই সাধারণ: তারিখ অনুযায়ী আসা লেনদেন, ক্রমিক রোল নম্বর, বাড়তে থাকা অর্ডার আইডি।

এর একটি সমাধান পর্ব ২৮ আর ২৯-এ দেখেছেন: AVL tree আর red-black tree. এরা কড়া নিয়ম রাখে — “দুই পাশের উচ্চতার পার্থক্য ১-এর বেশি নয়”, বা “প্রতি পথে সমান সংখ্যক কালো node” — আর প্রতিটি insert ও delete-এর পর নিয়ম ভাঙলে rotation আর রং বদলে মেরামত করে। ফল চমৎকার: worst case-এও O(log n). কিন্তু দাম আছে: red-black tree-র delete-এ অনেকগুলো আলাদা ক্ষেত্র, কনটেস্টের চাপে নির্ভুল লেখা কঠিন।

এই পর্বের প্রশ্ন: নিয়ম না মেনে কি ভারসাম্য পাওয়া যায়? উত্তর আসে একটি পুরোনো পর্যবেক্ষণ থেকে। Key-গুলো এলোমেলো ক্রমে ঢোকালে সাধারণ BST নিজে থেকেই প্রায় ভারসাম্যপূর্ণ হয় — প্রত্যাশিত গভীরতা O(log n). সমস্যা হলো, ইনপুটের ক্রম আমাদের হাতে নেই। কিন্তু যদি আমরা নিজেরাই প্রতিটি key-কে একটি এলোমেলো “লটারির টিকিট” দিই, আর tree-কে এমনভাবে সাজাই যেন key-গুলো টিকিটের ক্রমে এসেছিল? তখন ইনপুটের ক্রম যা-ই হোক, tree-র আকৃতি ঠিক হবে আমাদের লটারিতে। এটিই treap-এর মূল ভাবনা।

উপমা — ক্রিকেটের টস। দুই দলের অধিনায়ক যদি নিজেরাই ঠিক করেন কে আগে ব্যাট করবেন, শক্তিশালী পক্ষ সবসময় নিজের সুবিধা নেবে। টস সেই সুবিধা মুছে দেয়: কোন দল জিতবে তা কেউ আগে থেকে সাজাতে পারে না। Treap আর skip list-এ “প্রতিপক্ষ” হলো ইনপুট — হয়তো কোনো দুষ্ট ব্যবহারকারী ইচ্ছে করে সবচেয়ে খারাপ ক্রমে ডেটা পাঠাচ্ছেন। আমাদের নিজের মুদ্রা ছোড়া থাকলে তিনি আর আমাদের tree-কে শিকল বানাতে পারবেন না, কারণ মুদ্রা কোন দিকে পড়বে তা তিনি জানেন না।

এখানে একটি সূক্ষ্ম কিন্তু গুরুত্বপূর্ণ পার্থক্য আছে, যা পর্ব ৫ আর ২২-এ (randomized quicksort) দেখেছেন। Average case বিশ্লেষণে আমরা ইনপুট সম্পর্কে অনুমান করি (“ইনপুট এলোমেলো”). Randomized অ্যালগরিদমে ইনপুট সম্পর্কে কোনো অনুমান নেই; এলোমেলোতা আসে অ্যালগরিদমের ভেতর থেকে। তখন যে সীমা পাই, তার নাম expected running time: প্রতিটি ইনপুটের জন্য, আমাদের এলোমেলো পছন্দগুলোর ওপর গড়।

আরও একটি কথা: treap আর skip list সবসময় সঠিক উত্তর দেয়। এলোমেলোতা শুধু গতিকে প্রভাবিত করে, ফলকে নয়। এই ধরনের অ্যালগরিদমকে বলে Las Vegas অ্যালগরিদম (পর্ব ৬১-এ বিস্তারিত)। এই পর্বে দুটি কাঠামো দেখব: treap, যা BST-কে এলোমেলো করে, আর skip list, যা linked list-কে এলোমেলোভাবে কয়েক তলা উঁচু করে।

২ · Treap — key-তে BST, priority-তে heap

Treap শব্দটি tree আর heap জুড়ে বানানো। প্রতিটি node-এ দুটি সংখ্যা: একটি key (যা দিয়ে খুঁজব) আর একটি priority (node তৈরির সময় এলোমেলোভাবে বেছে নেওয়া)। Tree-টিকে একসাথে দুটি নিয়ম মানতে হয়:

  • Key-র দিক থেকে BST: প্রতিটি node-এর বাঁ subtree-র সব key ছোট, ডান subtree-র সব key বড় — পর্ব ২৭-এর BST property. তাই inorder traversal সবসময় সাজানো ক্রম দেয়।
  • Priority-র দিক থেকে heap: প্রতিটি node-এর priority তার সন্তানদের চেয়ে বড় — পর্ব ২৩-এর max-heap-এর heap property. তাই root-এ থাকে সবচেয়ে বড় priority-র key.

দুটি নিয়ম একসাথে মানা সম্ভব কি? হ্যাঁ, আর আকৃতিটি অনন্য: key আর priority সব আলাদা হলে এমন tree ঠিক একটিই আছে। কারণ: সবচেয়ে বড় priority-র key-কে root হতেই হবে (heap নিয়ম); তার চেয়ে ছোট key-গুলো বাঁ subtree-তে, বড়গুলো ডানে (BST নিয়ম); এখন দুই পাশে একই যুক্তি induction-এ খাটে। এই কাঠামো ১৯৮০ সালে Jean Vuillemin Cartesian tree নামে বর্ণনা করেন; Seidel আর Aragon-এর অবদান হলো priority-গুলো এলোমেলো বেছে নেওয়া, আর তা থেকে ভারসাম্যের প্রমাণ।

Insert — leaf-এ বসান, তারপর rotation দিয়ে ওপরে তুলুন

নতুন key x ঢোকাতে: (১) একটি এলোমেলো priority বেছে নিন; (২) সাধারণ BST-র মতো x-কে সঠিক leaf-এর জায়গায় বসান — BST নিয়ম ঠিক থাকে; (৩) এখন heap নিয়ম ভাঙতে পারে, যদি x-এর priority তার parent-এর চেয়ে বেশি হয়। তাহলে x আর parent-এর মধ্যে একটি rotation করুন: x বাঁ সন্তান হলে right rotation, ডান সন্তান হলে left rotation. Rotation BST নিয়ম ভাঙে না (পর্ব ২৮), আর x এক ধাপ ওপরে ওঠে। যতক্ষণ x-এর priority parent-এর চেয়ে বেশি, ততক্ষণ চালিয়ে যান — ঠিক heap-এর sift-up-এর মতো, শুধু অদলবদলের বদলে rotation.

নিচের ডায়াগ্রামে পাঁচটি key ঢোকানো হচ্ছে, প্রতিটির priority বন্ধনীতে দেওয়া (বোঝার সুবিধার জন্য হাতে বাছা; আসল treap-এ এগুলো এলোমেলো)। লেখা 50(40) মানে key 50, priority 40.

Treap — key(priority)সবুজ = এই ধাপে নতুন বা সরানো node

            
প্রতিটি ধাপের পর
এই ধাপের কাজ
heap নিয়ম
inorder
মোট rotation
উচ্চতা
  1. insert 50(40). খালি tree, তাই 50 সরাসরি root. দুই নিয়মই সহজে ঠিক।
  2. insert 30(70). BST নিয়মে 30 < 50, তাই 50-এর বাঁ সন্তান। কিন্তু 30-এর priority 70, parent 50-এর 40-এর চেয়ে বেশি — heap নিয়ম ভাঙল।
  3. Right rotation. 30 বাঁ সন্তান, তাই right rotation: 30 ওপরে, 50 তার ডান সন্তান। Inorder তবু 30 50 — rotation key-র ক্রম বদলায় না। এখন root-এর priority 70, সবচেয়ে বড়।
  4. insert 70(20). 70 > 30, ডানে; 70 > 50, আবার ডানে — 50-এর ডান সন্তান। Priority 20 < 40, heap ঠিক; কোনো rotation লাগল না।
  5. insert 40(90). 40 > 30 → ডানে; 40 < 50 → 50-এর বাঁয়ে। Priority 90 > 40: নিয়ম ভাঙা। নতুন key যত উঁচু priority পায়, তত বেশি ওপরে উঠবে।
  6. Right rotation (40, 50). 40 এখন 30-এর ডান সন্তান, 50 তার ডানে। কিন্তু 90 > 70 — parent 30-এর সাথেও নিয়ম ভাঙা। এক ধাপে কাজ শেষ হয়নি।
  7. Left rotation (40, 30). 40 ডান সন্তান, তাই left rotation: 40 root, 30 তার বাঁয়ে। Priority 90 সবার বড়, তাই 40-এর root হওয়াই ছিল নিয়তি — আকৃতিটি অনন্য।
  8. insert 60(50). 60 > 40 → ডানে; 60 > 50 → ডানে; 60 < 70 → 70-এর বাঁয়ে। Priority 50 > 20: নিয়ম ভাঙা।
  9. Right rotation (60, 70). 60 ওপরে, 70 তার ডান সন্তান। কিন্তু নতুন parent 50-এর priority 40 < 50 — আরও এক ধাপ।
  10. Left rotation (60, 50). 60 উঠল 40-এর ডান সন্তানের জায়গায়, তার বাঁয়ে 50, ডানে 70. 50 < 90, থামুন। পাঁচটি key, উচ্চতা ৩ — ভারসাম্যপূর্ণ, অথচ কোনো উচ্চতা বা রঙের হিসাব রাখতে হয়নি।

Delete — উল্টো পথে: rotation দিয়ে নিচে নামান, তারপর কেটে ফেলুন

Key x মুছতে প্রথমে x-কে খুঁজুন। x leaf হলে সরাসরি কেটে ফেলুন। না হলে x-কে নিচে নামান: x-এর দুই সন্তানের মধ্যে যার priority বেশি, তাকে rotation দিয়ে x-এর জায়গায় তুলুন (বাঁ সন্তান উঠলে right rotation, ডান সন্তান উঠলে left rotation). বেশি priority-র সন্তানকে তোলাই নিয়ম, কারণ সে-ই নতুন parent হিসেবে অন্য সন্তানের চেয়ে বড় — heap নিয়ম রক্ষা পায়। x যতক্ষণ leaf না হয়, চালিয়ে যান; তারপর কেটে ফেলুন। একটি বিকল্প চিন্তা: x-এর priority −∞ করে দিন, তাহলে heap নিয়ম তাকে নিচে ঠেলে দেবে। Seidel আর Aragon দেখান, insert বা delete প্রতিটিতে প্রত্যাশিত rotation-এর সংখ্যা ২-এর কম — ধ্রুবক, n-এর ওপর নির্ভর করে না।

Pseudocode — treap insert and delete with rotations (max-heap on priority)

insert(t, x):
    if t == NIL: return new node(x, random priority)
    if x < t.key:  t.left  = insert(t.left, x);  if t.left.pri  > t.pri: t = rotateRight(t)
    else:          t.right = insert(t.right, x); if t.right.pri > t.pri: t = rotateLeft(t)
    return t

delete(t, x):
    if t == NIL: return NIL
    if x < t.key: t.left = delete(t.left, x)
    elif x > t.key: t.right = delete(t.right, x)
    elif t is a leaf: return NIL
    elif t.right == NIL or (t.left != NIL and t.left.pri > t.right.pri):
        t = rotateRight(t);  t.right = delete(t.right, x)    // x moved down to the right
    else:
        t = rotateLeft(t);   t.left  = delete(t.left, x)     // x moved down to the left
    return t

৩ · কেন প্রত্যাশিত গভীরতা O(log n) — treap আসলে একটি random BST

প্রথম মূল পর্যবেক্ষণ: একটি treap ঠিক সেই BST, যা key-গুলোকে priority-র ক্রমে (বড় থেকে ছোট) সাধারণ BST-তে ঢুকিয়ে পাওয়া যায়। কারণ সেই BST-তেও প্রথমে ঢোকা (সবচেয়ে বড় priority-র) key root হয়, তারপর প্রতিটি key তার আগে ঢোকা — মানে বড় priority-র — key-গুলোর নিচে বসে। দুটি tree-ই অনন্যভাবে নির্ধারিত, তাই হুবহু এক।

এখন priority-গুলো স্বাধীনভাবে এলোমেলো, তাই তাদের ক্রম n! টি ক্রমের যেকোনোটি সমান সম্ভাবনায়। অর্থাৎ treap-টি একটি random BST — key-গুলো যেন একটি সম্পূর্ণ এলোমেলো ক্রমে এসেছিল। আসল ইনপুটের ক্রম (সাজানো, উল্টো, যা-ই হোক) আকৃতিতে কোনো প্রভাব ফেলে না। এটি randomized quicksort-এর সাথে হুবহু মেলে (পর্ব ২২): root হলো প্রথম pivot, যে key-গুলোকে দুই ভাগে ভাগ করে; প্রতিটি subtree-র root সেই ভাগের pivot.

প্রমাণ — কে কার পূর্বপুরুষ?

Key-গুলোকে সাজানো ক্রমে নাম দিন: x1 < x2 < … < xn. xk-এর গভীরতা = তার ancestor-এর (পূর্বপুরুষ) সংখ্যা। তাই প্রশ্ন: xi কখন xk-এর ancestor?

লেমা: i ≠ k হলে, xi হলো xk-এর ancestor যদি এবং কেবল যদি xi আর xk-এর মাঝের (দুই প্রান্তসহ) সব key-র মধ্যে xi-এর priority সবচেয়ে বড়।

যুক্তি: Key-গুলো priority-র ক্রমে ঢোকানোর কল্পনা করুন, আর রেঞ্জ R = {xi, …, xk} দেখুন (ধরুন i < k). R-এর কোনো key ঢোকার আগে যত key ঢুকেছে, সবাই R-এর বাইরে — হয় R-এর সবার চেয়ে ছোট, নয় সবার চেয়ে বড়। তাই BST-তে নামার সময় এরা R-এর সব key-কে একই দিকে পাঠায়: R-এর সবাই একই ফাঁকা জায়গার দিকে যাচ্ছে। এখন R থেকে প্রথম যে key ঢোকে (R-এ সবচেয়ে বড় priority-র), সে সেই জায়গায় বসে, আর R-এর বাকি সবাই তার নিচে যাবে। তিনটি ক্ষেত্র: (ক) সে xi নিজে — তাহলে xk তার নিচে, xi ancestor; (খ) সে xk — তাহলে উল্টো, xi ancestor নয়; (গ) সে মাঝের কোনো xj — তাহলে xi যায় তার বাঁয়ে আর xk ডানে, আলাদা subtree-তে; কেউ কারও ancestor নয়। ∎

R-এ |k − i| + 1 টি key, আর priority এলোমেলো, তাই প্রত্যেকের সর্বোচ্চ হওয়ার সম্ভাবনা সমান। ফলে

P(xi হলো xk-এর ancestor) = 1 / (|k − i| + 1)

এখন linearity of expectation: xk-এর ancestor-এর প্রত্যাশিত সংখ্যা = প্রতিটি i-এর জন্য ওপরের সম্ভাবনার যোগফল। বাঁ পাশের key-গুলো থেকে 1/2 + 1/3 + … + 1/k = Hk − 1, আর ডান পাশ থেকে Hn−k+1 − 1, যেখানে Hm = 1 + 1/2 + … + 1/m হলো harmonic সংখ্যা (পর্ব ৬), প্রায় ln m. তাই

E[xk-এর গভীরতা] = Hk + Hn−k+1 − 2 < 2 ln n ≈ 1.39 log₂ n

সুতরাং প্রতিটি খোঁজা, insert আর delete প্রত্যাশিত O(log n). পুরো tree-র উচ্চতাও প্রত্যাশিতভাবে O(log n) — random BST-র উচ্চতা নিয়ে Luc Devroye ১৯৮৬ সালে দেখান যে এটি প্রায় 4.311 ln n-এর কাছে থাকে, আর with high probability c log n-এর বেশি হয় না। জায়গা Θ(n): প্রতিটি node-এ শুধু একটি বাড়তি priority.

“প্রত্যাশিত” মানে “নিশ্চিত” নয়। প্রতিটি priority-র ক্রম সম্ভব, তাই খুব কম সম্ভাবনায় treap-ও শিকল হতে পারে — ঠিক যে ক্রমে priority কমছে, সেই ক্রমে key-ও বাড়লে। তবে সেই সম্ভাবনা মাত্র 2n−1/n! — n = 20-এই প্রায় 2 × 10−13, অত্যন্ত ক্ষুদ্র। আসল বিপদ অন্যখানে: priority যদি এলোমেলো না হয় — যেমন একই seed-এর জেনারেটর, যার আউটপুট আক্রমণকারী জানেন — তাহলে তিনি সত্যিই খারাপ ইনপুট বানাতে পারেন (অংশ ৭)।

৪ · Split আর merge — দুই কাজেই সব কাজ

Rotation দিয়ে treap লেখা যায়, কিন্তু কনটেস্টে আর বাস্তবে আরও জনপ্রিয় একটি পদ্ধতি আছে, যেখানে rotation-এর নামই নেই। সব কিছু দাঁড়িয়ে দুটি কাজের ওপর:

  • split(t, k): treap t-কে দুটি treap-এ ভাগ করে — L-এ k-এর চেয়ে ছোট সব key, R-এ বাকিগুলো (k বা তার বড়)।
  • merge(L, R): দুটি treap জোড়া দেয়, যখন L-এর সব key R-এর সব key-র চেয়ে ছোট।

Split কীভাবে: root-এর key k-এর চেয়ে ছোট হলে root আর তার পুরো বাঁ subtree নিশ্চিতভাবে L-এর অংশ; শুধু ডান subtree-তে মিশ্রণ আছে, তাই সেটিকে recursion-এ split করুন, আর ফলের ছোট অংশটিকে root-এর নতুন ডান সন্তান বানান। Root-এর key k বা বড় হলে উল্টোটা। প্রতিটি ধাপে এক স্তর নামি, তাই খরচ O(গভীরতা) = প্রত্যাশিত O(log n). Heap নিয়ম ভাঙে না, কারণ কোনো node-এর নতুন সন্তান সবসময় তার পুরোনো subtree থেকেই আসে।

Merge কীভাবে: L আর R-এর root-এর মধ্যে যার priority বেশি, সে-ই নতুন root. L-এর root জিতলে তার ডান সন্তান = merge(L-এর ডান subtree, R); R-এর root জিতলে তার বাঁ সন্তান = merge(L, R-এর বাঁ subtree). BST নিয়ম ঠিক থাকে, কারণ L-এর সবাই R-এর সবার চেয়ে ছোট। খরচ O(L-এর ডান পাশের দৈর্ঘ্য + R-এর বাঁ পাশের দৈর্ঘ্য) = প্রত্যাশিত O(log n).

এর পর বাকি সব এক লাইনের:

Pseudocode — everything from split and merge

insert(t, x):  (L, R) = split(t, x)
               return merge(merge(L, newNode(x)), R)
erase(t, x):   (L, R) = split(t, x)          // L: keys < x
               (M, R) = split(R, x + 1)      // M: exactly the key x (integer keys)
               return merge(L, R)
range(t, a, b): split twice to cut out the keys in [a, b) as one treap

আগের ডায়াগ্রামের শেষ treap নিয়ে split(t, 55) চালাই, তারপর key 55 (priority 60) ঢোকাই দুটি merge দিয়ে:

L, R আর বাকি অংশ — key(priority)সবুজ = এই ধাপে যার জায়গা ঠিক হলো

            
প্রতিটি ধাপের পর
এই ধাপের কাজ
L-এর key
R-এর key
ছোঁয়া node-এর পথ
  1. শুরু. Root 40, key 55-এ ভাগ করব: L পাবে 55-এর ছোট key, R পাবে 55 বা বড়।
  2. 40 < 55. 40 আর তার পুরো বাঁ subtree {30} নিশ্চিতভাবে L-এ। 40-এর ডান সন্তান কে হবে, তা এখনো জানি না — একটি “গর্ত”. Recursion নামে ডান সন্তান 60-এ।
  3. 60 ≥ 55. 60 আর তার ডান subtree {70} যায় R-এ। 60-এর বাঁ সন্তানের জায়গায় নতুন গর্ত। Recursion নামে 50-এ।
  4. 50 < 55. 50 যায় L-এ, আর বসে L-এর গর্তে — 40-এর ডান সন্তান হিসেবে। 50-এর ডানে নতুন গর্ত, recursion নামে NIL-এ।
  5. NIL. খালি tree-কে ভাগ করলে দুটোই খালি, তাই সব গর্ত NIL হয়ে যায়। L = {30, 40, 50}, R = {60, 70}. শুধু একটি পথ (40 → 60 → 50) ছুঁয়েছি — খরচ O(গভীরতা)। দুই ভাগেই heap নিয়ম অক্ষত।
  6. merge(L, 55(60)). 40-এর priority 90 > 60, তাই 40 root থাকে; তার ডানে merge(50(40), 55(60)): এবার 60 > 40, তাই 55 ওপরে আর 50 তার বাঁ সন্তান।
  7. merge(…, R). আবার 40 জেতে (90 > 50); তার ডানে merge(55(60), 60(50)): 60 > 50, তাই 55 থাকে ওপরে, তার ডানে 60, যার ডানে 70. Inorder 30 40 50 55 60 70 — সাজানো। Rotation দিয়ে ঢোকালে ঠিক এই tree-ই পেতেন, কারণ আকৃতি অনন্য।
মূল ধারণা

Split আর merge দুটোই একটি পথ ধরে চলে, তাই খরচ গভীরতার সমান — প্রত্যাশিত O(log n). এই দুটি দিয়ে যেকোনো রেঞ্জ “কেটে” আলাদা করা যায়, তার ওপর কাজ করে আবার জোড়া দেওয়া যায়। AVL বা red-black-এ split আর join সম্ভব, কিন্তু অনেক জটিল; treap-এ এগুলো দশ লাইনের।

৫ · Subtree-র আকার আর implicit treap — অবস্থান দিয়ে sequence

Order statistics — k-তম ছোট key

প্রতিটি node-এ তার subtree-র node-সংখ্যা sz রাখুন — পর্ব ২৭-এর order-statistic tree-এর মতো একটি augmented data structure. প্রতিটি split আর merge-এ যার সন্তান বদলায়, শুধু তার sz = 1 + sz(বাঁ) + sz(ডান) আবার হিসাব করলেই চলে (কোডে pull). তখন k-তম ছোট key খুঁজতে: root-এর বাঁ subtree-তে s টি key থাকলে root নিজে (s + 1)-তম। k ≤ s হলে বাঁয়ে যান; k = s + 1 হলে root-ই উত্তর; নইলে ডানে যান k − s − 1 খুঁজতে। একইভাবে “x-এর চেয়ে ছোট কয়টি key” (rank) বের করা যায়। দুটোই প্রত্যাশিত O(log n).

Treap — key [sz]সবুজ = এ পর্যন্ত হাঁটা পথ

            
প্রতিটি ধাপের পর
এই ধাপের কাজ
k
উত্তর
  1. শুরু. সাজানো ক্রমে key-গুলো 30 40 50 60 70; চতুর্থটি 60. দেখা যাক tree না ঘুরে শুধু আকার দেখে সেখানে পৌঁছানো যায় কি না।
  2. root 40. বাঁ subtree-র আকার ১, তাই 40 নিজে দ্বিতীয়। আমরা চাই চতুর্থ — ডানে যেতে হবে, আর ডান subtree-র ভেতরে খুঁজতে হবে 4 − 2 = দ্বিতীয়টি।
  3. 60. বাঁ subtree {50}-এর আকার ১, তাই এই subtree-তে 60 দ্বিতীয় = k. উত্তর 60, মাত্র দুটি node ছুঁয়ে।
  4. kth(1). 40-এর বাঁ subtree-র আকার ১ ≥ k = 1, তাই বাঁয়ে; 30-এর বাঁ subtree খালি, rank 1 = k. উত্তর 30. খরচ সবসময় একটি পথ — O(গভীরতা)।

Implicit treap — key ছাড়াই, শুধু অবস্থান

এবার একটি চমৎকার মোড়। ধরুন key বলে কিছু নেই, আছে শুধু একটি sequence — যেমন একটি লম্বা লেখার অক্ষরগুলো, বা খাতায় সাজানো নম্বরের সারি। প্রতিটি উপাদানের “key” হবে তার অবস্থান (index). কিন্তু অবস্থান সংরক্ষণ করব না, কারণ মাঝে একটি উপাদান ঢোকালে পরের সবার অবস্থান বদলায়। বদলে অবস্থান হিসাব করব sz থেকে: কোনো node-এর অবস্থান = তার বাঁয়ে কয়টি node আছে। এটিই implicit treap.

Split এখন key দিয়ে নয়, সংখ্যা দিয়ে: split(t, k) প্রথম k টি উপাদান L-এ দেয়, বাকিগুলো R-এ। Root-এর বাঁয়ে s টি থাকলে: s < k হলে root L-এ যায় আর ডানে খুঁজি k − s − 1 টি; নইলে root R-এ যায় আর বাঁয়ে খুঁজি k টি। Merge হুবহু আগের মতো। এখন:

  • অবস্থান p-তে ঢোকানো: split(t, p) → (L, R); merge(merge(L, নতুন node), R).
  • অবস্থান p মোছা: split(t, p) → (L, R); split(R, 1) → (M, R); merge(L, R).
  • [l, r] অংশ উল্টানো: দুবার split করে মাঝের অংশ M আলাদা করুন, M-এর root-এ একটি “উল্টাও” চিহ্ন বসান, তারপর জোড়া দিন। চিহ্নটি তখনই নিচে নামে, যখন কেউ ওই node-এর ভেতরে ঢোকে — এটিই lazy propagation (পর্ব ৩৫): চিহ্ন নামানোর সময় দুই সন্তান অদলবদল হয়, আর চিহ্ন দুই সন্তানে চলে যায়।
  • রেঞ্জের যোগফল বা সর্বনিম্ন: sz-এর মতো sum রাখুন, অংশটি কেটে root-এর sum পড়ুন।

প্রতিটি কাজ প্রত্যাশিত O(log n). Segment tree (পর্ব ৩৪) রেঞ্জের প্রশ্নে দ্রুত, কিন্তু মাঝে ঢোকানো, মোছা বা উল্টানো পারে না; implicit treap সব পারে। লম্বা লেখা সম্পাদনার জন্য এমন কাঠামোকে বলে rope. কনটেস্টে “একটি অ্যারের [l, r] অংশ উল্টাও, তারপর জিজ্ঞেস করো i-তম উপাদান কী” — এটি implicit treap-এর ক্লাসিক সমস্যা।

// implicit treap: no key stored; position = number of nodes to the left
struct T { char val; uint32_t pri; int sz = 1; bool rev = false; T *l = nullptr, *r = nullptr; };
int sz(T* t) { return t ? t->sz : 0; }
void push(T* t) {                                  // apply a pending "reverse" one level down
    if (t && t->rev) {
        swap(t->l, t->r);
        if (t->l) t->l->rev ^= 1;
        if (t->r) t->r->rev ^= 1;
        t->rev = false;
    }
}
void split(T* t, int k, T*& a, T*& b) {             // a = first k elements, b = the rest
    if (!t) { a = b = nullptr; return; }
    push(t);
    if (sz(t->l) < k) { split(t->r, k - sz(t->l) - 1, t->r, b); a = t; }
    else              { split(t->l, k, a, t->l); b = t; }
    t->sz = 1 + sz(t->l) + sz(t->r);
}
// merge(a, b) is the same as in the keyed treap, with push(a) / push(b) first
void reverseRange(T*& t, int l, int r) {            // reverse positions l..r (0-based, inclusive)
    T *a, *b, *m, *c;
    split(t, l, a, b); split(b, r - l + 1, m, c);
    m->rev ^= 1;                                    // O(1): the work is done lazily later
    t = merge(a, merge(m, c));
}

যেমন, sequence a b c d e f-এ reverseRange(1, 3) দিলে: split(t, 1) → a আর b c d e f; তারপর split(…, 3) → b c d আর e f; মাঝের অংশে চিহ্ন; জোড়া দিলে inorder a d c b e f.

পুরো কোড — fixed seed-এর treap, split/merge দিয়ে

নিচের প্রোগ্রাম আটটি key ঢোকায়, একটি মোছে, তারপর inorder, আকার, উচ্চতা আর তৃতীয় ছোট key দেখায়। এরপর আসল পরীক্ষা: 1 থেকে 10000 সাজানো ক্রমে ঢোকানো — সাধারণ BST-র সবচেয়ে খারাপ ইনপুট — তারপর 3-এর সব গুণিতক মোছা, আর ফলটি একটি সাজানো রেফারেন্স তালিকার সাথে মেলানো। Priority আসে xorshift32 নামের একটি ছোট pseudorandom জেনারেটর থেকে, একটি নির্দিষ্ট seed (1491) দিয়ে। C++ আর Python-এ একই সূত্র, তাই দুই ভাষায় হুবহু একই priority, একই tree আর একই আউটপুট।

#include <algorithm>
#include <cstdint>
#include <initializer_list>
#include <iostream>
#include <vector>
using namespace std;
uint32_t state = 1491;                                    // fixed seed: same priorities on every run
uint32_t rnd() { state ^= state << 13; state ^= state >> 17; state ^= state << 5; return state; }  // xorshift32
struct Node { int key; uint32_t pri; int sz = 1; Node *l = nullptr, *r = nullptr; };
int sz(Node* t) { return t ? t->sz : 0; }
Node* pull(Node* t) { t->sz = 1 + sz(t->l) + sz(t->r); return t; }
void split(Node* t, int k, Node*& a, Node*& b) {          // a: keys < k, b: keys >= k
    if (!t) { a = b = nullptr; return; }
    if (t->key < k) { split(t->r, k, t->r, b); a = pull(t); }
    else            { split(t->l, k, a, t->l); b = pull(t); }
}
Node* merge(Node* a, Node* b) {                           // every key of a < every key of b
    if (!a || !b) return a ? a : b;
    if (a->pri > b->pri) { a->r = merge(a->r, b); return pull(a); }
    b->l = merge(a, b->l); return pull(b);
}
Node* insert(Node* t, int k) { Node *a, *b; split(t, k, a, b); return merge(merge(a, new Node{k, rnd()}), b); }
Node* erase(Node* t, int k) { Node *a, *b, *m, *c; split(t, k, a, b); split(b, k + 1, m, c); delete m; return merge(a, c); }
int kth(Node* t, int k) {                                 // 1-based k-th smallest, using subtree sizes
    while (sz(t->l) + 1 != k) if (k <= sz(t->l)) t = t->l; else { k -= sz(t->l) + 1; t = t->r; }
    return t->key;
}
int height(Node* t) { return t ? 1 + max(height(t->l), height(t->r)) : 0; }
void inorder(Node* t, vector<int>& out) { if (t) { inorder(t->l, out); out.push_back(t->key); inorder(t->r, out); } }
int main() {
    Node* t = nullptr;
    for (int k : {50, 30, 70, 20, 40, 60, 80, 10}) t = insert(t, k);
    t = erase(t, 30);
    vector<int> v; inorder(t, v); cout << "inorder:"; for (int x : v) cout << ' ' << x;
    cout << "\nsize " << sz(t) << ", height " << height(t) << ", 3rd smallest " << kth(t, 3) << '\n';
    const int N = 10000; Node* s = nullptr; vector<int> ref, w;   // sorted input: a plain BST would be a chain
    for (int k = 1; k <= N; ++k) { s = insert(s, k); if (k % 3) ref.push_back(k); }
    for (int k = 3; k <= N; k += 3) s = erase(s, k);
    bool kok = true; for (size_t i = 0; i < ref.size(); i += 97) kok = kok && kth(s, int(i) + 1) == ref[i];
    inorder(s, w);
    cout << "n = " << sz(s) << ", height " << height(s) << " (plain BST: " << N << ")\n";
    cout << "matches sorted reference: " << (w == ref ? "yes" : "no") << " | kth ok: " << (kok ? "yes" : "no") << '\n';
}

আউটপুট:

inorder: 10 20 40 50 60 70 80
size 7, height 4, 3rd smallest 40
n = 6667, height 29 (plain BST: 10000)
matches sorted reference: yes | kth ok: yes

প্রথম লাইনে inorder সাজানো, আর 30 নেই — erase দুটি split দিয়ে ঠিক সেই key-টি কেটে বাদ দিয়েছে। সাতটি key-র treap-এর উচ্চতা ৪, যেখানে সবচেয়ে ভালো সম্ভব ৩ — এলোমেলোতা নিখুঁত ভারসাম্য দেয় না, দেয় “যথেষ্ট ভালো”. তৃতীয় ছোট key 40 এসেছে kth-এর আকার-ভিত্তিক হাঁটা থেকে। আসল খবর তৃতীয় লাইনে: সাজানো 10000 টি key ঢোকানোর পর সাধারণ BST-র উচ্চতা হতো 10000, কিন্তু treap-এর উচ্চতা মাত্র ২৯ (log₂ 6667 ≈ ১২.৭; উচ্চতা তার দুই গুণের কিছু বেশি — ধ্রুবক গুণক, প্রত্যাশামতো)। একই tree-তে আলাদাভাবে গড় গভীরতা (ancestor-এর সংখ্যা) মাপলে প্রায় ১৪ আসে, 2 ln n ≈ ১৭.৬-এর নিচে — অংশ ৩-এর সূত্রের সাথে মিল। শেষ লাইনে রেফারেন্সের সাথে পুরো inorder আর প্রতি ৯৭-তম অবস্থানের kth মিলেছে। প্রতিটি কাজ প্রত্যাশিত O(log n); মোট জায়গা Θ(n).

class Node:
    __slots__ = ("key", "pri", "sz", "l", "r")
    def __init__(self, key, pri): self.key, self.pri, self.sz, self.l, self.r = key, pri, 1, None, None
state = 1491                               # fixed seed: same priorities on every run
def rnd():                                 # xorshift32, bit-for-bit the same as the C++ version
    global state
    state ^= (state << 13) & 0xFFFFFFFF; state ^= state >> 17; state ^= (state << 5) & 0xFFFFFFFF
    return state
def sz(t): return t.sz if t else 0
def pull(t): t.sz = 1 + sz(t.l) + sz(t.r); return t
def split(t, k):                           # returns (keys < k, keys >= k)
    if t is None: return None, None
    if t.key < k:
        t.r, b = split(t.r, k); return pull(t), b
    a, t.l = split(t.l, k); return a, pull(t)
def merge(a, b):                           # every key of a < every key of b
    if a is None or b is None: return a or b
    if a.pri > b.pri:
        a.r = merge(a.r, b); return pull(a)
    b.l = merge(a, b.l); return pull(b)
def insert(t, k): a, b = split(t, k); return merge(merge(a, Node(k, rnd())), b)
def erase(t, k): a, b = split(t, k); _, c = split(b, k + 1); return merge(a, c)
def kth(t, k):                             # 1-based k-th smallest, using subtree sizes
    while sz(t.l) + 1 != k:
        if k <= sz(t.l): t = t.l
        else: k, t = k - sz(t.l) - 1, t.r
    return t.key
def height(t): return 1 + max(height(t.l), height(t.r)) if t else 0
def inorder(t, out):
    if t: inorder(t.l, out); out.append(t.key); inorder(t.r, out)
    return out
t = None
for k in (50, 30, 70, 20, 40, 60, 80, 10): t = insert(t, k)
t = erase(t, 30)
print("inorder:", *inorder(t, []))
print(f"size {sz(t)}, height {height(t)}, 3rd smallest {kth(t, 3)}")
N, s = 10000, None                         # sorted input: a plain BST would become a chain
for k in range(1, N + 1): s = insert(s, k)
for k in range(3, N + 1, 3): s = erase(s, k)
ref = [k for k in range(1, N + 1) if k % 3]
kok = all(kth(s, i + 1) == ref[i] for i in range(0, len(ref), 97))
print(f"n = {sz(s)}, height {height(s)} (plain BST: {N})")
print("matches sorted reference:", "yes" if inorder(s, []) == ref else "no", "| kth ok:", "yes" if kok else "no")

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

inorder: 10 20 40 50 60 70 80
size 7, height 4, 3rd smallest 40
n = 6667, height 29 (plain BST: 10000)
matches sorted reference: yes | kth ok: yes

দুই ভাষায় একই আউটপুটের রহস্য rnd: C++-এ uint32_t নিজে থেকেই 32 বিটে কেটে যায়, Python-এর int অসীম বড় হতে পারে, তাই প্রতিটি বাঁ-shift-এর পর & 0xFFFFFFFF দিয়ে 32 বিটে কাটা হয়েছে। split Python-এ দুটি treap একটি tuple হিসেবে ফেরত দেয়, আর t.r, b = split(t.r, k) এক লাইনে ডান সন্তান বদলায়। Recursion-এর গভীরতা উচ্চতার সমান (এখানে ৩০-এর কম), Python-এর ১০০০-এর সীমার অনেক নিচে — সাধারণ BST হলে ১০০০০ গভীর recursion-এ RecursionError হতো।

৬ · Skip list — কয়েক তলার linked list আর মুদ্রা নিক্ষেপ

সাজানো linked list-এ খোঁজা O(n), কারণ binary search চলে না — মাঝখানে লাফ দেওয়ার উপায় নেই। William Pugh-এর প্রশ্ন ছিল: যদি কিছু node-এর একটি বাড়তি pointer থাকে, যা অনেক দূরে লাফ দেয়?

উপমা — ঢাকা–চট্টগ্রাম রেলপথ। লোকাল ট্রেন প্রতিটি স্টেশনে থামে; আন্তঃনগর ট্রেন থামে শুধু বড় কয়েকটি স্টেশনে। আপনি যদি একটি ছোট স্টেশনে যেতে চান, বুদ্ধিমানের কাজ হলো আন্তঃনগরে চড়ে গন্তব্যের ঠিক আগের বড় স্টেশন পর্যন্ত যাওয়া, তারপর লোকালে নেমে বাকি পথ। Skip list-এ এমন কয়েকটি তলা: সবচেয়ে নিচের তলা (level 0) লোকাল — সব key; ওপরের তলাগুলো ক্রমশ দ্রুত, কম স্টেশনের ট্রেন।

যদি ঠিক প্রতি দ্বিতীয় node level 1-এ, প্রতি চতুর্থ node level 2-এ, এভাবে থাকে, তাহলে এটি binary search-এর মতো log₂ n তলা আর O(log n) খোঁজা দেয়। কিন্তু insert আর delete-এ সেই নিখুঁত বিন্যাস রাখতে পুরো কাঠামো বদলাতে হতো। Pugh-এর চতুর সমাধান: নিখুঁত বিন্যাস নয়, শুধু সঠিক অনুপাত. নতুন key ঢোকানোর সময় একটি মুদ্রা ছুড়ুন: heads হলে এক তলা উঁচু করুন, আবার ছুড়ুন; tails হলেই থামুন। তাহলে প্রায় অর্ধেক key level 1-এ, প্রায় এক-চতুর্থাংশ level 2-এ — গড়ে ঠিক সেই অনুপাত, কোনো পুনর্গঠন ছাড়াই। একটি key-র তলার সংখ্যা একটি geometric distribution মানে।

খোঁজার নিয়ম: সবচেয়ে ওপরের তলায় head (H) থেকে শুরু করুন। সামনের node-এর key খোঁজা key-র চেয়ে ছোট হলে সামনে এগোন; না হলে এক তলা নামুন। Level 0-তে থামার পর ঠিক পরের node-টিই হয় খোঁজা key, নয়তো key-টি নেই। নিচের ডায়াগ্রামে দশটি key, আর মুদ্রার ফল ঠিক এই পর্বের প্রোগ্রামের seed থেকে আসা (H = heads, T = tails):

চার তলা (L3 ওপরে), H = headসবুজ = নতুন key বা খোঁজার অবস্থান

            
প্রতিটি ধাপের পর
এই ধাপের কাজ
মুদ্রা → তলা
মোট pointer
খোঁজার পথ
  1. শুরু. শুধু head H আর শেষের NIL, প্রতিটি তলায়। প্রতিটি তলা একটি সাজানো linked list.
  2. insert 12. প্রথম মুদ্রাতেই tails, তাই 12 শুধু level 0-তে।
  3. insert 3. Heads, তারপর tails — 3 দুই তলা উঁচু: level 0 আর level 1-এ। প্রতিটি তলায় ঠিক জায়গায় জুড়তে হয়, খোঁজার পথে প্রতি তলার শেষ node মনে রেখে।
  4. insert 25, 7, 19. তিনজনেরই প্রথম মুদ্রায় tails — শুধু নিচের তলা। ঢোকানোর ক্রম যা-ই হোক, নিচের তলা সবসময় সাজানো।
  5. insert 9. H T → দুই তলা। এখন level 1-এ 3 আর 9 — একটি এক্সপ্রেস লাইন।
  6. insert 17. H H T → তিন তলা। Level 2-এ প্রথম স্টেশন।
  7. insert 21. তিনবার heads → চার তলা, সবচেয়ে উঁচু। এমন হওয়ার সম্ভাবনা 1/8 — দশটি key-র মধ্যে একটি এমন হওয়া খুবই স্বাভাবিক।
  8. insert 6. H T → দুই তলা।
  9. insert 26. H H T → তিন তলা। শেষ কাঠামো প্রোগ্রামের আউটপুটের সাথে হুবহু এক। দশটি key-তে ঠিক ২০টি pointer — প্রত্যাশিত 2n-এর সমান (এবার ভাগ্যক্রমে নিখুঁত)।
  10. search(19). L3-এ H-এর পরে 21 > 19, নামুন। L2-এ 17 < 19, এগোন; পরে 21 > 19, নামুন। L1-এ 17-এর পরে 21, নামুন। L0-তে 17-এর পরে 19 — পাওয়া গেল। মাঝের 3, 6, 7, 9, 12 কাউকে ছুঁতে হয়নি।
  11. search(10). L3-এ 21, L2-এ 17 — দুটোই 10-এর বড়, তাই H থেকেই নামা। L1-এ 3 → 6 → 9, তারপর 17 > 10. L0-তে 9-এর পরে 12 > 10, থামুন। 12 ≠ 10, তাই 10 নেই। প্রতিটি তলায় মাত্র কয়েকটি ধাপ।

Insert: খোঁজার মতো নামুন, আর প্রতি তলায় যে node-এ থামলেন (update[i]) তা মনে রাখুন। মুদ্রা ছুড়ে তলার সংখ্যা lv ঠিক করুন। তারপর প্রতিটি তলা i < lv-তে নতুন node-কে update[i]-এর পরে জুড়ুন — linked list-এর সাধারণ insert, প্রতি তলায় দুটি pointer. Delete: একইভাবে update[i] জমা করুন, আর key-টি যে যে তলায় আছে, সেখানে update[i].next[i]-কে তার পরেরটিতে সরিয়ে দিন। কোনো rotation নেই, কোনো রং নেই; শুধু স্থানীয় pointer বদল।

বিশ্লেষণ — কেন প্রত্যাশিত O(log n) সময় আর O(n) জায়গা

জায়গা. প্রতিটি key কমপক্ষে level 0-তে; level i-তেও থাকার সম্ভাবনা (1/2)i. তাই একটি key-র প্রত্যাশিত pointer-সংখ্যা 1 + 1/2 + 1/4 + … = 2 — একটি geometric series (পর্ব ৬)। n টি key-তে প্রত্যাশিত 2n pointer: জায়গা প্রত্যাশিত O(n). সাধারণভাবে, পরের তলায় ওঠার সম্ভাবনা p হলে প্রতি key-তে 1/(1 − p) pointer; Pugh নিজে অনেক ক্ষেত্রে p = 1/4 সুপারিশ করেন — প্রায় ১.৩৩ pointer, খোঁজা সামান্য ধীর।

উচ্চতা. একটি নির্দিষ্ট key-র তলা h-এর বেশি হওয়ার সম্ভাবনা (1/2)h. Union bound-এ, n টির যেকোনো একটির তলা h-এর বেশি হওয়ার সম্ভাবনা ≤ n/2h. h = 2 log₂ n বসালে এটি ≤ 1/n. তাই with high probability উচ্চতা ≤ 2 log₂ n, আর প্রত্যাশিত উচ্চতা ≤ log₂ n + 2. (বাস্তবে একটি সর্বোচ্চ তলা MAXL বেঁধে দেওয়া হয়, যেমন ১৬ বা ৩২.)

খোঁজার পথ — উল্টো দিক থেকে দেখুন. এই যুক্তির নাম backward analysis. খোঁজা শেষ হয় level 0-র একটি node-এ। সেখান থেকে পথটি উল্টো দিকে হাঁটুন: প্রতিটি node-এ, যদি সেই node-এর আরও ওপরের তলা থাকে, পথটি সেখানে ওপর থেকে নেমেছিল — উল্টো দিকে আমরা ওপরে উঠি; না থাকলে পথটি বাঁ দিক থেকে এসেছিল — উল্টো দিকে আমরা বাঁয়ে যাই। প্রতিটি node-এর ওপরের তলা আছে কি না, তা একটি স্বাধীন মুদ্রা, সম্ভাবনা 1/2. তাই প্রতিটি এক তলা ওঠার আগে গড়ে ২টি ধাপ। প্রায় log₂ n তলা উঠতে প্রত্যাশিত প্রায় 2 log₂ n ধাপ, আর ওপরের কয়েকটি তলায় মাত্র কয়েকটি key থাকে বলে বাকি অংশ O(1). সুতরাং search প্রত্যাশিত O(log n); insert আর delete তার সাথে প্রতি তলায় O(1) কাজ যোগ করে — একই O(log n).

Pseudocode — skip list (levels 0 .. MAXL-1, head H on every level)

search(k):  x = H
            for i = top down to 0:
                while x.next[i] != NIL and x.next[i].key < k:  x = x.next[i]
                update[i] = x                          // last node before k on level i
            y = x.next[0];  return y != NIL and y.key == k
insert(k):  search(k); if found: return
            lv = 1;  while lv < MAXL and coin() == heads:  lv = lv + 1
            for i = 0 to lv-1:  new.next[i] = update[i].next[i];  update[i].next[i] = new
delete(k):  search(k); if not found: return
            for i = 0 to (levels of y) - 1:  update[i].next[i] = y.next[i]

পুরো কোড — fixed seed-এর skip list, তলা আর খোঁজার পথসহ

প্রোগ্রামটি ডায়াগ্রামের দশটি key একই ক্রমে ঢোকায়, প্রতিটি তলা ছাপে, দুটি খোঁজার পথ দেখায় (L2@17 মানে “level 2-এ খোঁজা থেমেছে 17-এ, তারপর নেমেছে”), তারপর আরও ২০০০টি এলোমেলো key ঢুকিয়ে একটি set-এর সাথে মেলায়। Node-গুলো অ্যারেতে, pointer মানে index, আর −1 মানে NIL.

#include <cstdint>
#include <iostream>
#include <set>
#include <string>
#include <vector>
using namespace std;
const int MAXL = 16;
uint32_t state = 1491;                                    // fixed seed: same levels on every run
uint32_t rnd() { state ^= state << 13; state ^= state >> 17; state ^= state << 5; return state; }  // xorshift32
vector<int> key = {-1};                                   // node 0 is the head H
vector<vector<int>> nxt = {vector<int>(MAXL, -1)};        // nxt[x][i] = next node on level i, -1 = NIL
vector<int> walk(int k, vector<string>* path = nullptr) { // last node before k on every level, top to bottom
    vector<int> upd(MAXL); int x = 0;
    for (int i = MAXL - 1; i >= 0; --i) {
        while (nxt[x][i] != -1 && key[nxt[x][i]] < k) x = nxt[x][i];
        upd[i] = x;
        if (path && nxt[0][i] != -1) path->push_back("L" + to_string(i) + "@" + (x ? to_string(key[x]) : "H"));
    }
    return upd;
}
bool contains(int k, vector<string>* path = nullptr) { int y = nxt[walk(k, path)[0]][0]; return y != -1 && key[y] == k; }
void insert(int k) {
    vector<int> upd = walk(k); int y = nxt[upd[0]][0];
    if (y != -1 && key[y] == k) return;                   // already present
    int lv = 1; while (lv < MAXL && (rnd() & 1)) ++lv;    // coin flips: heads = one more level
    int me = key.size(); key.push_back(k); nxt.push_back(vector<int>(lv, -1));
    for (int i = 0; i < lv; ++i) { nxt[me][i] = nxt[upd[i]][i]; nxt[upd[i]][i] = me; }
}
int main() {
    set<int> ref; int demo[] = {12, 3, 25, 7, 19, 9, 17, 21, 6, 26}, queries[] = {19, 10};
    for (int k : demo) { insert(k); ref.insert(k); }
    for (int i = MAXL - 1; i >= 0; --i) if (nxt[0][i] != -1) {
        cout << "L" << i << ": H"; for (int x = nxt[0][i]; x != -1; x = nxt[x][i]) cout << ' ' << key[x]; cout << '\n'; }
    for (int k : queries) {
        vector<string> path; bool ok = contains(k, &path);
        cout << "search " << k << ":"; for (auto& p : path) cout << ' ' << p; cout << " -> " << (ok ? "found" : "absent") << '\n'; }
    for (int r = 0; r < 2000; ++r) { int k = rnd() % 5000; insert(k); ref.insert(k); }
    vector<int> level0; size_t ptrs = 0, top = 0;
    for (int x = nxt[0][0]; x != -1; x = nxt[x][0]) { level0.push_back(key[x]); ptrs += nxt[x].size(); if (nxt[x].size() > top) top = nxt[x].size(); }
    bool agree = true; for (int k = 0; k < 5000; ++k) agree = agree && contains(k) == (ref.count(k) > 0);
    cout << "keys " << level0.size() << " | pointers " << ptrs << " | levels used " << top << '\n';
    cout << "level 0 matches sorted reference: " << (level0 == vector<int>(ref.begin(), ref.end()) ? "yes" : "no") << '\n';
    cout << "all searches agree: " << (agree ? "yes" : "no") << '\n';
}

আউটপুট:

L3: H 21
L2: H 17 21 26
L1: H 3 6 9 17 21 26
L0: H 3 6 7 9 12 17 19 21 25 26
search 19: L3@H L2@17 L1@17 L0@17 -> found
search 10: L3@H L2@H L1@9 L0@9 -> absent
keys 1679 | pointers 3381 | levels used 10
level 0 matches sorted reference: yes
all searches agree: yes

প্রথম চার লাইন ডায়াগ্রামের শেষ কাঠামো, আর দুটি পথ ডায়াগ্রামের শেষ দুই ধাপ। এরপর ২০০০টি এলোমেলো সংখ্যা (০–৪৯৯৯) থেকে কিছু পুনরাবৃত্তি বাদে মোট ১৬৭৯টি আলাদা key. Pointer ৩৩৮১টি — প্রতি key-তে প্রায় ২.০১, প্রত্যাশিত ২-এর একেবারে কাছে: জায়গা O(n)-এর সরাসরি প্রমাণ। ব্যবহৃত তলা ১০, আর log₂ 1679 ≈ ১০.৭ — উচ্চতা প্রায় log₂ n, যেমন বিশ্লেষণ বলেছিল। শেষ দুই লাইনে level 0 হুবহু সাজানো রেফারেন্স, আর ০ থেকে ৪৯৯৯ পর্যন্ত প্রতিটি খোঁজার উত্তর std::set-এর সাথে মিলেছে — এটিই stress testing.

MAXL = 16
state = 1491                               # fixed seed: same levels on every run
def rnd():                                 # xorshift32, bit-for-bit the same as the C++ version
    global state
    state ^= (state << 13) & 0xFFFFFFFF; state ^= state >> 17; state ^= (state << 5) & 0xFFFFFFFF
    return state
key, nxt = [-1], [[-1] * MAXL]             # node 0 is the head H; nxt[x][i] = next on level i, -1 = NIL
def walk(k, path=None):                    # last node before k on every level, top to bottom
    upd, x = [0] * MAXL, 0
    for i in range(MAXL - 1, -1, -1):
        while nxt[x][i] != -1 and key[nxt[x][i]] < k: x = nxt[x][i]
        upd[i] = x
        if path is not None and nxt[0][i] != -1: path.append(f"L{i}@{key[x] if x else 'H'}")
    return upd
def contains(k, path=None):
    y = nxt[walk(k, path)[0]][0]
    return y != -1 and key[y] == k
def insert(k):
    upd = walk(k); y = nxt[upd[0]][0]
    if y != -1 and key[y] == k: return     # already present
    lv = 1
    while lv < MAXL and rnd() & 1: lv += 1 # coin flips: heads = one more level
    key.append(k); nxt.append([-1] * lv); me = len(key) - 1
    for i in range(lv): nxt[me][i], nxt[upd[i]][i] = nxt[upd[i]][i], me
ref = set()
for k in (12, 3, 25, 7, 19, 9, 17, 21, 6, 26): insert(k); ref.add(k)
for i in range(MAXL - 1, -1, -1):
    x, row = nxt[0][i], []
    while x != -1: row.append(key[x]); x = nxt[x][i]
    if row: print(f"L{i}: H", *row)
for k in (19, 10):
    path = []; ok = contains(k, path)
    print(f"search {k}:", *path, "->", "found" if ok else "absent")
for _ in range(2000): k = rnd() % 5000; insert(k); ref.add(k)
level0, x = [], nxt[0][0]
while x != -1: level0.append(key[x]); x = nxt[x][0]
print(f"keys {len(level0)} | pointers {sum(len(v) for v in nxt[1:])} | levels used {max(len(v) for v in nxt[1:])}")
print("level 0 matches sorted reference:", "yes" if level0 == sorted(ref) else "no")
print("all searches agree:", "yes" if all(contains(k) == (k in ref) for k in range(5000)) else "no")

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

L3: H 21
L2: H 17 21 26
L1: H 3 6 9 17 21 26
L0: H 3 6 7 9 12 17 19 21 25 26
search 19: L3@H L2@17 L1@17 L0@17 -> found
search 10: L3@H L2@H L1@9 L0@9 -> absent
keys 1679 | pointers 3381 | levels used 10
level 0 matches sorted reference: yes
all searches agree: yes

দুই ভাষায় মুদ্রা একই, কারণ দুটোই একই xorshift32 আর একই seed, আর মুদ্রা ছোড়া হয় একই মুহূর্তে — শুধু নতুন key ঢোকানোর সময়, পুনরাবৃত্ত key-তে নয়। লক্ষ করুন, এলোমেলো key বানাতে (rnd() % 5000) আর মুদ্রা ছুড়তে একই জেনারেটর ব্যবহার হচ্ছে; ক্রম একটুও বদলালে (যেমন key বানানোর আগে মুদ্রা) পুরো কাঠামো বদলে যেত, তাই দুই ভাষায় এই ক্রম হুবহু এক রাখা হয়েছে।

৭ · বাস্তবে — concurrency, Redis, LevelDB আর নির্দিষ্ট seed

কেন concurrent সিস্টেমে skip list

যখন একসাথে অনেক thread একই কাঠামো পড়ে আর লেখে — একটি concurrent data structure — তখন balanced tree-র rotation বড় সমস্যা: একটি rotation একসাথে কয়েকটি node-এর সম্পর্ক বদলায়, আর red-black tree-র মেরামত root পর্যন্ত ছড়াতে পারে। পুরো tree-তে একটি lock দিলে সব thread লাইনে দাঁড়ায়, যেন একটি ব্যস্ত বাজারে একটিমাত্র ক্যাশ কাউন্টার। Skip list-এ একটি insert শুধু কয়েকটি পাশাপাশি pointer বদলায়, আর প্রতিটি তলার linked list আলাদাভাবে, নিচ থেকে ওপরে, জোড়া যায়। এতে সূক্ষ্ম lock বা পুরোপুরি lock-free নকশা (compare-and-swap দিয়ে) তুলনামূলক সহজ। Java-র ConcurrentSkipListMap (Java 6 থেকে) এর একটি পরিচিত উদাহরণ।

  • Redis-এর sorted set: Redis-এর sorted set (ZSET) ডেটা টাইপ বড় হলে ভেতরে একটি skip list (score অনুযায়ী সাজানো) আর একটি hash table (সদস্য থেকে score) একসাথে রাখে; ছোট sorted set রাখা হয় আরও সংক্ষিপ্ত একটি তালিকায়। লিডারবোর্ডে “১০০ থেকে ১২০তম কে” বা “এই score-এর রেঞ্জে কারা” — skip list-এর level 0 ধরে হাঁটলেই। Redis নিজে একটি thread-এ কমান্ড চালায়, তাই সেখানে পছন্দের কারণ concurrency নয় — সরলতা আর রেঞ্জ ধরে হাঁটার সুবিধা।
  • LevelDB আর RocksDB-এর memtable: এই LSM-tree ভিত্তিক key-value ডেটাবেসে নতুন লেখা প্রথমে মেমরির একটি সাজানো কাঠামোয় জমে — memtable, যার ডিফল্ট রূপ একটি skip list. LevelDB-র skip list-এ লেখার জন্য বাইরে থেকে সমন্বয় লাগে, কিন্তু লেখা চলার সময়ও পড়ার জন্য lock লাগে না; RocksDB-র skip list memtable একাধিক thread-এর একসাথে লেখাও সমর্থন করে।

তবে বিনামূল্যে কিছু নেই। Skip list-এর node-গুলো মেমরিতে ছড়ানো, আর প্রতিটি পদক্ষেপ একটি pointer অনুসরণ — cache locality খারাপ। একই কাজে একটি B-tree ধাঁচের কাঠামো (পর্ব ৩০) অনেক সময় দ্রুততর, বিশেষ করে শুধু একটি thread থাকলে।

নির্দিষ্টতা আর পুনরুৎপাদন — seed কেন নির্দিষ্ট রাখবেন

Randomized কোডে বাগ ধরা কঠিন: একবার ভুল আসে, পরের বার আসে না, কারণ মুদ্রা অন্যভাবে পড়েছে। তাই পরীক্ষার সময় জেনারেটরকে একটি নির্দিষ্ট seed দিন — তখন প্রতিবার হুবহু একই সংখ্যার ধারা, একই tree, একই আউটপুট। এটিই reproducibility: যে ইনপুটে বাগ এসেছিল, সেটি আবার চালিয়ে আবার একই বাগ দেখা যায়। এই পর্বের দুটি প্রোগ্রামই তাই seed 1491 দিয়ে চলে, আর তাদের আউটপুট আপনার কম্পিউটারেও হুবহু ওপরের মতো হবে।

কিন্তু প্রোডাকশনে বা কনটেস্টে উল্টো চিন্তা লাগে। Seed যদি সবার জানা হয়, তাহলে priority-গুলোও সবার জানা — কেউ ঠিক সেই ক্রমে key পাঠাতে পারেন, যাতে treap শিকল হয়ে যায়। এটি পর্ব ৩২-এর hash flooding-এর মতোই একটি adversarial input. তাই বাস্তবে seed নেওয়া হয় অনুমান করা যায় না এমন উৎস থেকে — যেমন C++-এ random_device বা ঘড়ির সময়, Python-এ seed না দিলেই random মডিউল অপারেটিং সিস্টেমের এলোমেলো উৎস ব্যবহার করে। ভালো অভ্যাস: seed প্রোগ্রামের শুরুতে লগ করুন, যাতে দরকার হলে সেই চালানোটি আবার তৈরি করা যায়।

৮ · তুলনা আর ইতিহাস — কখন কোনটি

কাঠামোsearch / insert / deleteপ্রতি update-এ পুনর্গঠনsplit / mergeকোথায় বেশি চলে
AVL (পর্ব ২৮)O(log n) worst caseinsert-এ সর্বোচ্চ একটি single বা double rotation; delete-এ O(log n) পর্যন্তসম্ভব, জটিলখোঁজা-প্রধান কাজ; উচ্চতা সবচেয়ে কম
Red-black (পর্ব ২৯)O(log n) worst caseinsert-এ ≤ ২, delete-এ ≤ ৩ rotation, আর রং বদলসম্ভব, জটিলstd::map, Java TreeMap, Linux kernel
B-tree (পর্ব ৩০)O(log n) worst case, ডিস্কে O(logB n) block পড়াnode split বা mergeসম্ভবডেটাবেস আর ফাইল সিস্টেম
Splayamortized O(log n); একক কাজ O(n) পর্যন্তপ্রতিটি কাজে, খোঁজাতেও, root পর্যন্ত rotationসহজযেখানে কিছু key বারবার চাওয়া হয়
Treapপ্রত্যাশিত O(log n), যেকোনো ইনপুটেপ্রত্যাশিত < ২ rotation (বা split/merge)খুব সহজকনটেস্ট, implicit sequence, rope
Skip listপ্রত্যাশিত O(log n), যেকোনো ইনপুটেকোনো rotation নেই; প্রতি তলায় স্থানীয় pointer বদলমোটামুটি সহজRedis sorted set, LevelDB/RocksDB memtable, concurrent map

সংক্ষেপে: প্রতিটি কাজের কঠোর সীমা লাগলে (real-time সিস্টেম, লাইব্রেরি) red-black বা AVL; ডিস্ক হলে B-tree; নিজে দ্রুত নির্ভুল লিখতে হবে, সাথে split/merge বা sequence-এর কাজ লাগলে treap; অনেক thread একসাথে বা রেঞ্জ ধরে হাঁটা সহজ চাইলে skip list. শুধু set বা map লাগলে কনটেস্টে আগে std::set/std::map ভাবুন — নিজের treap লিখবেন তখনই, যখন লাইব্রেরি যা পারে না তা লাগবে: k-তম উপাদান, রেঞ্জ কাটা, sequence উল্টানো।

ইতিহাস. Jean Vuillemin ১৯৮০ সালে Cartesian tree বর্ণনা করেন — key-তে BST, মানে heap. ১৯৮৯ সালে Raimund Seidel আর Cecilia Aragon FOCS কনফারেন্সে “Randomized Search Trees” উপস্থাপন করেন: মান হিসেবে এলোমেলো priority, আর তা থেকে প্রত্যাশিত O(log n) আর প্রতি update-এ প্রত্যাশিত ধ্রুবক rotation; পূর্ণ জার্নাল সংস্করণ বের হয় ১৯৯৬ সালে Algorithmica-য়। প্রায় একই সময়ে William Pugh skip list উপস্থাপন করেন (১৯৮৯ সালে একটি technical report-এ), আর ১৯৯০ সালের জুনে Communications of the ACM-এ প্রকাশ করেন “Skip Lists: A Probabilistic Alternative to Balanced Trees”. ১৯৯৮ সালে Conrado Martínez আর Salvador Roura “randomized BST” দেন, যেখানে আলাদা priority না রেখে subtree-র আকার দিয়ে এলোমেলো সিদ্ধান্ত নেওয়া হয়।

আরও গভীরে — history independence আর zip tree: Priority এলোমেলো না নিয়ে key-র একটি ভালো hash থেকে নিলে (priority = hash(key)) treap-এর আকৃতি শুধু বর্তমান key-গুলোর ওপর নির্ভর করে, কোন ক্রমে ঢোকানো বা মোছা হয়েছিল তার ওপর নয় — এই গুণের নাম history independence. এতে tree-র আকৃতি দেখে কেউ অতীতের কাজ অনুমান করতে পারে না, আর দুটি সমান set-এর tree হুবহু এক হয় (তুলনা আর hashing সহজ)। আর ২০১৯ সালে Robert Tarjan, Caleb Levy ও Stephen Timmel “zip tree” প্রস্তাব করেন: প্রতিটি node-এর rank geometric distribution থেকে, ঠিক skip list-এর তলার মতো — একই সাথে treap আর skip list-এর একটি সেতু।

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

  • Treap-এর প্রত্যাশিত উচ্চতা কত, আর এটি কিসের ওপর নির্ভর করে? — O(log n); শুধু priority-র এলোমেলোতার ওপর, ইনপুটের ক্রমের ওপর নয়। Worst case O(n), তবে সম্ভাবনা নগণ্য।
  • p = 1/2-এর skip list-এ n টি key-র প্রত্যাশিত জায়গা? — প্রায় 2n টি pointer, অর্থাৎ O(n); প্রতি key-তে 1 + 1/2 + 1/4 + … = 2.
  • Treap-এ x-এর priority তার parent-এর চেয়ে বড় হলে কী করবেন? — x বাঁ সন্তান হলে right rotation, ডান সন্তান হলে left rotation, যতক্ষণ heap নিয়ম না মেলে।
  • একটি অ্যারের [l, r] অংশ বারবার উল্টাতে হবে, সাথে i-তম উপাদান জানতে হবে — কোন কাঠামো? — Implicit treap: দুবার split, মাঝের অংশে lazy reverse চিহ্ন, দুবার merge; প্রত্যাশিত O(log n).
  • Treap কি Las Vegas না Monte Carlo? — Las Vegas: উত্তর সবসময় সঠিক, শুধু সময় এলোমেলো।

৯ · AI-এর যুগে treap আর skip list — এলোমেলো কোড, নির্দিষ্ট যাচাই

“Treap লিখে দাও” — AI সহকারী মিনিটে চল্লিশ লাইন দেবে, আর ছোট উদাহরণে প্রায়ই ঠিক উত্তরও আসবে। কিন্তু randomized কাঠামোর ভুল সবচেয়ে চতুরভাবে লুকায়: একটি ভুল priority-তুলনা tree-কে শুধু ধীর করে, ভুল নয় — inorder তবু সাজানো আসে। Complexity-র প্রশ্নে AI প্রায়ই “treap সবসময় O(log n)” বলে, “প্রত্যাশিত” শব্দটি বাদ দিয়ে।

Treap আর skip list-এর কোডে AI সাধারণত যেসব ভুল করে

AI-এর ভুলকীভাবে ধরবেন
merge-এ priority-র বদলে key তুলনাinorder ঠিক আসে, কিন্তু সাজানো 104 key-তে উচ্চতা ছাপুন — হাজারের ঘরে গেলে ভুল
split বা merge-এর পর sz হালনাগাদ (pull) বাদপ্রতিটি কাজের পর সব node-এ sz == 1 + sz(l) + sz(r) assert করুন; k-তম উত্তর brute force-এর সাথে মেলান
Implicit treap-এ নামার আগে push না করাএকই অংশ দুবার উল্টে মূল sequence ফেরত আসে কি না দেখুন
প্রতিবার srand(time(0)) ডেকে মুদ্রা, বা শুধু rand() % 2-এর দুর্বল bitদশ হাজার key-তে প্রতি তলার key-সংখ্যা ছাপুন: প্রায় n/2, n/4, … আসা উচিত
Skip list-এ সর্বোচ্চ তলা (MAXL) না বাঁধা, বা insert-এ ওপরের তলাগুলোর update বাদলম্বা heads-এর পর অ্যারের বাইরে লেখা; প্রতিটি তলা সাজানো কি না যাচাই করুন
“Treap worst case O(log n)”, “skip list amortized O(log n)”সঠিক বাক্য চান: প্রত্যাশিত O(log n), যেকোনো ইনপুটে; worst case O(n), অতি ক্ষুদ্র সম্ভাবনায়
পরীক্ষায় seed ছাড়া random — প্রতিবার ভিন্ন আউটপুটনির্দিষ্ট seed দিয়ে দুবার চালান; আউটপুট হুবহু এক না হলে কোথাও লুকানো random আছে

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

আমি DSA শিখছি, এখন treap (split/merge) আর skip list. নিচে আমার কোড আর একটি ছোট ইনপুটে আমার হাতে করা trace দিলাম: [এখানে কোড আর trace পেস্ট করুন] পুরো সমাধান নতুন করে লিখে দিও না। শুধু এগুলো করো: ১. key আর priority: (20,5), (10,8), (30,9), (25,7) — max-heap নিয়মে এই ক্রমে insert করলে প্রতিটি rotation-এর পর tree কেমন হয়, ধাপে ধাপে দেখাও, আর আমার trace-এর সাথে কোথায় অমিল বলো। ২. আমার split আর merge কি প্রতিটি পরিবর্তিত node-এ subtree-র আকার ঠিক রাখে? একটি প্রতি-উদাহরণ খোঁজো। ৩. treap-এর প্রত্যাশিত গভীরতা O(log n) — ancestor-এর সম্ভাবনা 1/(|i-k|+1) দিয়ে প্রমাণটি লেখো, আর বলো "প্রত্যাশিত" কথাটি কেন worst case নয়। ৪. আমার skip list-এর মুদ্রা কি সত্যিই প্রায় অর্ধেক অর্ধেক? কীভাবে পরীক্ষা করব, আর আমার seed নীতি পরীক্ষা ও প্রোডাকশনে কেমন হওয়া উচিত?

Treap আর skip list যাচাইয়ের চেকলিস্ট

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

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

প্র ০১Cartesian tree থেকে treap — একটি পুরোনো কাঠামোয় শুধু “এলোমেলো” শব্দটি যোগ করে কীভাবে একটি নতুন ডেটা স্ট্রাকচার জন্ম নিল?

১৯৮০ সালে ফরাসি কম্পিউটার বিজ্ঞানী Jean Vuillemin Communications of the ACM-এ একটি প্রবন্ধে Cartesian tree বর্ণনা করেন। মূল প্রশ্ন ছিল জ্যামিতিক: সমতলের বিন্দুগুলো এমনভাবে রাখা, যাতে x অনুযায়ী খোঁজা যায় আর y অনুযায়ী সবচেয়ে উঁচু বিন্দু দ্রুত পাওয়া যায়। প্রতিটি বিন্দুর x হলো BST-র key, y হলো heap-এর মান। কাছাকাছি সময়ে Edward McCreight-এর priority search tree (১৯৮৫) একই ধরনের মিশ্রণ ব্যবহার করে রেঞ্জ প্রশ্নের জন্য। এই কাঠামোগুলোতে দ্বিতীয় মানটি ছিল ডেটার অংশ — তাই আকৃতি ডেটার ওপর নির্ভর করত, আর খারাপ ডেটায় tree লম্বা হতো।

Raimund Seidel আর Cecilia Aragon-এর পর্যবেক্ষণ ছিল উল্টো দিক থেকে। দ্বিতীয় মানটি যদি ডেটার অংশ না হয়, যদি আমরা নিজেরা প্রতিটি key-কে একটি এলোমেলো সংখ্যা দিই — তাহলে Cartesian tree-র আকৃতি একটি random BST-র মতো হয়, ইনপুট যা-ই হোক। ১৯৮৯ সালে FOCS কনফারেন্সে তাঁরা এটি “Randomized Search Trees” নামে উপস্থাপন করেন, আর প্রমাণ করেন প্রত্যাশিত খোঁজার খরচ O(log n), প্রতি update-এ প্রত্যাশিত rotation ধ্রুবক (২-এর কম), আর split ও join-এর মতো কাজও সরল।

এখানে একটি গভীর শিক্ষা আছে। Random BST-র তত্ত্ব আগে থেকেই জানা ছিল — quicksort-এর বিশ্লেষণ থেকে, যেখানে random pivot ঠিক একই tree তৈরি করে। কিন্তু সেই তত্ত্ব “ইনপুট এলোমেলো হলে” কথা বলত, যা বাস্তবে নিশ্চিত নয়। Treap সেই শর্তটি ইনপুট থেকে সরিয়ে অ্যালগরিদমের ভেতরে নিয়ে আসে। একই কৌশল আধুনিক অ্যালগরিদমে বারবার ফিরে আসে: randomized quicksort, universal hashing (পর্ব ৩২), randomized incremental geometry.

মূল উপলব্ধি: “ইনপুট এলোমেলো হলে ভালো চলে” — এমন যেকোনো অ্যালগরিদমকে প্রায়ই “আমরাই এলোমেলো করে দিই” বলে সব ইনপুটের জন্য ভালো বানানো যায়। Treap সেই রূপান্তরের সবচেয়ে সুন্দর উদাহরণগুলোর একটি।

প্র ০২Pugh কেন skip list-কে balanced tree-র “সম্ভাবনাভিত্তিক বিকল্প” বললেন, আর এত বছর পরও কেন Redis বা ডেটাবেসের মতো সিস্টেম এটি বেছে নেয়?

১৯৮০-এর দশকের শেষে ইউনিভার্সিটি অব মেরিল্যান্ডের William Pugh একটি বাস্তব অসুবিধা লক্ষ করেন: balanced tree-র তত্ত্ব পরিপক্ব, কিন্তু প্রোগ্রামাররা সেগুলো নির্ভুল লিখতে হিমশিম খান — বিশেষ করে delete. ১৯৮৯ সালের একটি technical report-এর পর তাঁর ১৯৯০ সালের CACM প্রবন্ধ “Skip Lists: A Probabilistic Alternative to Balanced Trees”-এর মূল দাবি ছিল দ্বিমুখী: প্রত্যাশিত গতি balanced tree-র সমান বা কাছাকাছি, অথচ কোড অনেক সরল, কারণ কোনো rotation বা রঙের নিয়ম নেই — শুধু linked list-এর insert আর delete, কয়েক তলায়।

Pugh আরও দেখান যে খারাপ কাঠামো পাওয়ার সম্ভাবনা ভীষণ দ্রুত কমে যায় — বড় n-এ প্রত্যাশার চেয়ে কয়েক গুণ ধীর হওয়ার সম্ভাবনা কার্যত শূন্য। পরের বছরগুলোতে তিনি concurrent skip list নিয়েও কাজ করেন, আর পরবর্তীতে অন্য গবেষকরা lock-free skip list-এর নকশা দেন। এর বাস্তব ফল দেখা যায় Java-য়: Doug Lea-র লেখা ConcurrentSkipListMap Java 6-এ যুক্ত হয়, আর এটিই Java-র standard library-র sorted concurrent map.

Redis-এর sorted set একটি ভিন্ন গল্প, কারণ Redis কমান্ড একটি thread-এ চালায়। এর স্রষ্টা Salvatore Sanfilippo প্রকাশ্য আলোচনায় skip list পছন্দের যে কারণগুলো বলেছিলেন বলে পরিচিত, তা মূলত তিনটি: কাঠামোটি বুঝতে, লিখতে আর debug করতে সহজ; ZRANGE-এর মতো রেঞ্জ কমান্ডে level 0 ধরে linked list-এর মতো হাঁটা যায়; আর p বদলে মেমরি আর গতির ভারসাম্য সহজে নিয়ন্ত্রণ করা যায়।

মূল উপলব্ধি: প্রকৌশলে “সরল আর যথেষ্ট দ্রুত” প্রায়ই “নিখুঁত কিন্তু জটিল”-কে হারায়। Skip list-এর জয় তত্ত্বে নয়, কোডের সরলতা, রেঞ্জ ধরে হাঁটার সুবিধা আর concurrency-তে।

প্র ০৩এলোমেলোতা আর নির্দিষ্টতার দ্বন্দ্ব — কেন পরীক্ষায় নির্দিষ্ট seed চাই, অথচ প্রোডাকশন আর কনটেস্টে নির্দিষ্ট seed বিপজ্জনক হতে পারে?

কম্পিউটার আসলে মুদ্রা ছোড়ে না। rand(), std::mt19937 বা Python-এর random — সবই pseudorandom: একটি নির্দিষ্ট সূত্র, যা শুরুর অবস্থা (seed) থেকে একটি দীর্ঘ, এলোমেলো দেখতে ধারা বানায়। Seed জানলে পুরো ধারা জানা। Mersenne Twister (Matsumoto ও Nishimura, ১৯৯৮) পরিসংখ্যানের পরীক্ষায় চমৎকার, কিন্তু তার আউটপুটের কয়েকশো সংখ্যা দেখে ভেতরের অবস্থা পুরোটা বের করা যায় — তাই এটি নিরাপত্তার কাজে চলে না।

পরীক্ষার দিক থেকে নির্দিষ্টতা আশীর্বাদ। একটি randomized কাঠামোর বাগ হয়তো এক লাখে একবার আসে — বিশেষ কোনো priority-বিন্যাসে। Seed নির্দিষ্ট না থাকলে সেই বাগ আর কখনো দেখা নাও যেতে পারে। তাই ভালো test suite seed লগ করে, ব্যর্থ হলে সেই seed দিয়ে আবার চালায়, আর debug শেষে একই seed-এ নিশ্চিত হয় যে বাগ সত্যিই গেছে।

অন্য দিকে, প্রতিপক্ষ যদি seed জানেন, এলোমেলোতার সব সুরক্ষা উধাও। ২০০৩ সালে Scott Crosby আর Dan Wallach “algorithmic complexity attack” নিয়ে একটি পরিচিত গবেষণা প্রকাশ করেন: hash table-এর মতো কাঠামোর গড় খরচ ভালো, কিন্তু আক্রমণকারী worst-case ইনপুট পাঠিয়ে সার্ভার ধীর করে দিতে পারেন। Treap-এর ক্ষেত্রেও একই কথা: priority অনুমানযোগ্য হলে কেউ এমন ক্রমে key পাঠাতে পারেন, যাতে tree শিকল হয়। কনটেস্টে এর একটি পরিচিত রূপ আছে — যেসব প্ল্যাটফর্মে অন্যের সমাধান “hack” করা যায়, সেখানে কোনো সমাধান নির্দিষ্ট seed-এর random ব্যবহার করলে অন্যরা কোড পড়ে সেই seed-এর জন্য খারাপ ইনপুট বানাতে পারেন। তাই কনটেস্টে সাধারণ পরামর্শ: ঘড়ির সময় বা random_device দিয়ে seed দিন।

দুটি চাহিদা মেলানোর উপায় সহজ: প্রোডাকশনে অনুমান-অযোগ্য seed নিন, কিন্তু সেটি লগে লিখে রাখুন। তখন আক্রমণকারী আগে থেকে জানেন না, অথচ আপনি পরে যেকোনো ঘটনা হুবহু আবার তৈরি করতে পারেন।

মূল উপলব্ধি: Randomized অ্যালগরিদমের নিশ্চয়তা আসে প্রতিপক্ষের অজ্ঞতা থেকে। Seed লুকান প্রতিপক্ষের কাছ থেকে, কিন্তু নিজের কাছ থেকে কখনো নয়।

প্র ০৪Rope আর implicit treap — একটি টেক্সট এডিটর লম্বা লেখার মাঝখানে অক্ষর ঢোকায় কীভাবে, আর কনটেস্টে এই ধারণা কেন এত কাজের?

ধরুন একটি এডিটরে এক কোটি অক্ষরের একটি ফাইল খোলা। লেখাটি যদি একটি সাধারণ অ্যারে (বা C-এর string) হয়, তাহলে শুরুর কাছে একটি অক্ষর ঢোকালে পরের প্রায় এক কোটি অক্ষর এক ঘর করে সরাতে হয় — প্রতিটি কী-চাপে O(n). ১৯৯৫ সালে Hans-J. Boehm, Russ Atkinson আর Michael Plass “Ropes: an Alternative to Strings” প্রবন্ধে একটি বিকল্প দেন: লেখাটিকে ছোট ছোট টুকরোয় ভাগ করে একটি balanced binary tree-র leaf-এ রাখুন, আর প্রতিটি internal node জানুক তার বাঁ পাশে কতগুলো অক্ষর। তখন i-তম অক্ষর খোঁজা, মাঝখানে ঢোকানো, একটি অংশ কেটে অন্য জায়গায় বসানো — সবই tree-র উচ্চতার সমান খরচে।

Implicit treap ঠিক এই ধারণার সবচেয়ে সরল রূপ: প্রতিটি node একটি অক্ষর, “বাঁ পাশে কতগুলো” তথ্যটি sz, আর ভারসাম্য আসে এলোমেলো priority থেকে। Split মানে লেখাটি একটি অবস্থানে কাটা, merge মানে দুটি অংশ জোড়া — কপি-পেস্টের হুবহু প্রতিরূপ। বাস্তব এডিটরগুলো নানা কাঠামো ব্যবহার করে: piece table (মূল ফাইল আর নতুন লেখার আলাদা buffer, আর টুকরোর একটি তালিকা), gap buffer, বা rope. যেমন Visual Studio Code ২০১৮ সালে তার text buffer নতুন করে লেখে একটি “piece tree”-তে — piece table-এর টুকরোগুলো একটি red-black tree-তে রাখা। সব নকশার মূল কথা এক: পুরো লেখা সরানো চলবে না, শুধু কিছু pointer বদলাতে হবে।

কনটেস্টে implicit treap একটি “সুইস আর্মি নাইফ”. Segment tree রেঞ্জের যোগফল বা সর্বনিম্ন দ্রুত দেয়, কিন্তু অ্যারের আকার আর ক্রম স্থির ধরে নেয়। যখন সমস্যা বলে “অংশ [l, r] উল্টাও”, “অংশটি কেটে শুরুতে বসাও”, “অবস্থান p-তে নতুন সংখ্যা ঢোকাও” — আর সাথে রেঞ্জের যোগফলও চাই — তখন implicit treap প্রায় একমাত্র সহজ উপায়। একই কাঠামোয় lazy চিহ্ন দিয়ে “অংশে x যোগ করো” বা “অংশ উল্টাও” দুটোই রাখা যায়; শুধু push-এর সময় চিহ্নগুলোর ক্রম ঠিক রাখতে হয়।

মূল উপলব্ধি: “অবস্থান” সংরক্ষণ না করে “বাঁয়ে কয়টি” থেকে হিসাব করলে sequence-এর মাঝখানে পরিবর্তন সস্তা হয়ে যায়। এডিটরের rope আর কনটেস্টের implicit treap একই ধারণার দুই রূপ।

প্র ০৫“প্রত্যাশিত O(log n)” আর “worst case O(log n)” — বাস্তবে এই পার্থক্য কখন সত্যিই গুরুত্বপূর্ণ, আর std::map কেন treap নয়?

গাণিতিকভাবে treap খারাপ হওয়ার সম্ভাবনা এত ক্ষুদ্র যে বাস্তবে প্রায় কখনো দেখা যায় না। তাহলে C++-এর std::map, Java-র TreeMap বা Linux kernel কেন red-black tree বেছে নিল?

প্রথম কারণ ঐতিহাসিক আর মানগত। C++ standard library-র নকশা হয়েছিল Alexander Stepanov-এর STL থেকে, ১৯৯০-এর দশকের শুরুতে; সেখানে map-এর প্রতিটি কাজের জন্য worst-case লগারিদমিক সীমা লেখা হয়। একটি লাইব্রেরি যা কোটি কোটি প্রোগ্রামে চলবে, সেখানে “প্রায় সবসময়” নয়, “সবসময়” প্রতিশ্রুতি দেওয়া সহজ বিক্রি। দ্বিতীয় কারণ এলোমেলোতার উৎস: লাইব্রেরিকে একটি random জেনারেটর রাখতে হবে, তার seed কোথা থেকে, thread-safe কি না, আর পুনরুৎপাদন কীভাবে — এসব প্রশ্নের উত্তর সবার জন্য এক হয় না।

যেখানে worst case সত্যিই জরুরি: real-time সিস্টেম (গাড়ির ব্রেক নিয়ন্ত্রণ, পেসমেকার, শিল্প-রোবট), যেখানে প্রতিটি কাজের একটি সময়সীমা আছে, আর গড়ে দ্রুত হওয়া যথেষ্ট নয়; আর নিরাপত্তা-সংবেদনশীল সার্ভার, যেখানে প্রতিপক্ষ এলোমেলোতা অনুমান করার চেষ্টা করতে পারে। অন্য দিকে, অনেক বাস্তব সিস্টেম জেনে-বুঝে প্রত্যাশিত সীমা নেয়: hash table প্রায় সবখানে, randomized quicksort-এর রূপ অনেক লাইব্রেরিতে (সাথে worst-case-এর জন্য বিকল্প পথ, যেমন introsort), আর skip list Redis ও ডেটাবেসে। এখানে বিচার্য হলো সরলতা, concurrency আর গড় গতি।

একটি সূক্ষ্ম কথা: “প্রত্যাশিত” আর “amortized” আলাদা। Splay tree-র amortized সীমা প্রতিটি ইনপুট-ক্রমের জন্য নিশ্চিত, কিন্তু একটি একক কাজ O(n) হতে পারে। Treap-এর প্রত্যাশিত সীমা প্রতিটি কাজের জন্য, কিন্তু ভাগ্যের ওপর। দুটিকে মিশিয়ে ফেলা পরীক্ষা আর ইন্টারভিউতে খুব সাধারণ ভুল।

মূল উপলব্ধি: নিশ্চয়তার ধরন — worst case, amortized, প্রত্যাশিত — একটি নকশা-সিদ্ধান্ত। বেশিরভাগ কাজে প্রত্যাশিত সীমা যথেষ্ট ও সরল; কিন্তু প্রতিশ্রুতি যখন প্রতিটি কাজের জন্য, তখন worst-case কাঠামো লাগে।

অনুশীলন

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

  1. হাতে trace করুন: খালি treap-এ (বড় priority ওপরে) এই ক্রমে key(priority) ঢোকান: 20(5), 10(8), 30(9), 25(7). প্রতিটি insert-এর পর tree আঁকুন আর rotation গুনুন। তারপর যাচাই করুন: priority-র ক্রমে (বড় থেকে ছোট) সাধারণ BST-তে ঢোকালে কি একই tree আসে?

    20(5): root. 10(8): 20-এর বাঁয়ে; 8 > 5, right rotation → root 10, ডানে 20 (১টি rotation). 30(9): 10 → ডানে 20 → ডানে, 20-এর ডান সন্তান; 9 > 5 → left rotation, 30 এখন 10-এর ডান সন্তান, তার বাঁয়ে 20; 9 > 8 → আবার left rotation, root 30, বাঁয়ে 10, 10-এর ডানে 20 (আরও ২টি)। 25(7): 30-এর বাঁয়ে → 10-এর ডানে → 20-এর ডানে; 7 > 5 → left rotation, 25 এখন 10-এর ডান সন্তান, তার বাঁয়ে 20; 7 < 8, থামুন (আরও ১টি)।

    শেষ tree: root 30(9); তার বাঁয়ে 10(8); 10-এর ডানে 25(7); 25-এর বাঁয়ে 20(5). মোট rotation ৪. যাচাই: priority-র ক্রমে key-গুলো 30, 10, 25, 20. সাধারণ BST-তে: 30 root; 10 বাঁয়ে; 25 → 30-এর বাঁয়ে → 10-এর ডানে; 20 → 10-এর ডানে → 25-এর বাঁয়ে। হুবহু এক — অংশ ৩-এর মূল পর্যবেক্ষণ। উচ্চতা ৪, চারটি key-তে শিকলের মতো — ছোট n-এ এলোমেলোতা খারাপ আকৃতি দিতেই পারে; প্রত্যাশা কথা বলে বড় n-এ।

  2. হিসাব করুন: সাজানো key x1 < x2 < x3 < x4, priority এলোমেলো। (ক) x1 হলো x4-এর ancestor — এর সম্ভাবনা কত? (খ) x1-এর প্রত্যাশিত ancestor-সংখ্যা কত? (গ) x2-এর?

    (ক) রেঞ্জ {x1, …, x4}-এ চারটি key; x1-এর priority সবচেয়ে বড় হওয়ার সম্ভাবনা 1/4.

    (খ) x2, x3, x4 প্রত্যেকে x1-এর ancestor হওয়ার সম্ভাবনা 1/2, 1/3, 1/4. Linearity of expectation-এ যোগফল 6/12 + 4/12 + 3/12 = 13/12 ≈ 1.08. সূত্রে: H1 + H4 − 2 = 1 + 25/12 − 2 = 13/12.

    (গ) x2-এর জন্য: x1 থেকে 1/2, x3 থেকে 1/2, x4 থেকে 1/3 — যোগফল 4/3 ≈ 1.33; সূত্রে H2 + H3 − 2 = 3/2 + 11/6 − 2 = 4/3. মাঝের key-গুলোর প্রত্যাশিত গভীরতা প্রান্তের চেয়ে বেশি, কারণ দুই পাশ থেকেই ancestor আসতে পারে।

  3. ভেবে বলুন: (ক) একটি skip list-এ p = 1/4 (পরের তলায় ওঠার সম্ভাবনা) আর n = 4096 টি key. প্রত্যাশিত মোট pointer কত, আর প্রায় কত তলা লাগবে? (খ) Sequence 1 2 3 4 5 6-এ implicit treap দিয়ে প্রথমে [1, 4] অংশ (0-based, দুই প্রান্তসহ) উল্টান, তারপর [0, 2]. শেষ sequence কী, আর প্রতিটি উল্টানোয় কয়টি split ও merge লাগে?

    (ক) প্রতি key-তে প্রত্যাশিত pointer 1/(1 − p) = 1/(3/4) = 4/3, তাই মোট প্রায় 4096 × 4/3 ≈ ৫৪৬১. তলার সংখ্যা প্রায় log4 n = log4 4096 = ৬ (আর কয়েকটি বাড়তি তলা ঘটতে পারে)। p = 1/2 হলে লাগত প্রায় 8192 pointer আর ১২ তলা — p ছোট করলে জায়গা আর তলা কমে, কিন্তু প্রতি তলায় হাঁটা বাড়ে (গড়ে 1/p ধাপ)।

    (খ) [1, 4] উল্টালে 1 5 4 3 2 6; তারপর [0, 2] উল্টালে 4 5 1 3 2 6. প্রতিটি উল্টানোয় ২টি split (মাঝের অংশ আলাদা করতে), মাঝের root-এ একটি lazy চিহ্ন (O(1)), আর ২টি merge — প্রত্যাশিত O(log n). প্রথম উল্টানোর চিহ্ন হয়তো তখনো পুরো নিচে নামেনি, কিন্তু দ্বিতীয় split নামার সময় push করে, তাই অবস্থানগুলো সঠিক থাকে।

  4. কোড লিখুন: এই পর্বের skip list প্রোগ্রামের কাঠামো (key, nxt, walk) ব্যবহার করে erase(k) লিখুন: key থাকলে প্রতিটি তলা থেকে তাকে সরান, আর true ফেরত দিন; না থাকলে false. তারপর কয়েকটি key মুছে প্রতিটি তলা ছেপে দেখুন সব তলা সাজানো আছে কি না।

    walk(k) প্রতিটি তলায় k-এর আগের শেষ node দেয় — ঠিক যা দরকার। মুছতে হবে শুধু সেসব তলায়, যেখানে সেই node-এর পরেরটিই y.

    // uses key, nxt, walk from the skip list program
    bool erase(int k) {
        vector<int> upd = walk(k);
        int y = nxt[upd[0]][0];
        if (y == -1 || key[y] != k) return false;          // not present
        for (int i = 0; i < (int)nxt[y].size(); ++i)        // only the levels y lives on
            if (nxt[upd[i]][i] == y) nxt[upd[i]][i] = nxt[y][i];
        return true;                                       // y is unlinked (its slot stays unused)
    }

    পরীক্ষা: ডায়াগ্রামের list থেকে erase(21) করলে L3 ফাঁকা হয় (শুধু H), L2 হয় H 17 26, L1 H 3 6 9 17 26. তারপর erase(10) false দেয়। খরচ প্রত্যাশিত O(log n) — একটি খোঁজা আর y-এর তলা-সংখ্যার সমান কাজ (প্রত্যাশিত ২)। অ্যারের ঘরটি অব্যবহৃত পড়ে থাকে; দীর্ঘ চলা প্রোগ্রামে একটি free-list রেখে ঘরগুলো আবার কাজে লাগানো যায়।

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

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

ক্রমSkip list-এ একটি নতুন key ঢোকানোর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. সবচেয়ে ওপরের তলা থেকে খোঁজার মতো নামা, আর প্রতি তলায় key-র আগের শেষ node (update) মনে রাখা
  2. Level 0-তে পরের node-টিই সেই key কি না দেখা — থাকলে কিছু না করে থামা
  3. মুদ্রা ছুড়ে নতুন node-এর তলার সংখ্যা ঠিক করা (tails পড়া বা সর্বোচ্চ তলা পর্যন্ত)
  4. সেই সংখ্যক pointer-সহ নতুন node তৈরি করা
  5. প্রতিটি তলায় নতুন node-কে update node-এর ঠিক পরে জুড়ে দেওয়া

একটি treap-এ (বড় priority ওপরে) চারটি key(priority): 10(3), 20(9), 30(5), 40(7). কোন key root হবে, আর root-এর ডান সন্তান কে?

  • root 10, ডান সন্তান 20
  • root 20, ডান সন্তান 40
  • root 20, ডান সন্তান 30
  • root 40, ডান সন্তান কেউ নেই

এলোমেলো priority-র treap-এ সাজানো key x1 < x2 < … < x10. x3 হলো x7-এর ancestor — এর সম্ভাবনা কত?

  • 1/4
  • 1/10
  • 1/5
  • 1/2

একটি skip list-এ প্রতিটি key পরের তলায় ওঠে 1/2 সম্ভাবনায়। n টি key-তে মোট forward pointer-এর প্রত্যাশিত সংখ্যা কত?

  • প্রায় 2n
  • ঠিক n
  • প্রায় n log₂ n
  • প্রায় n²/2

এলোমেলো priority-র treap-এর খোঁজার খরচ সম্পর্কে কোন বক্তব্যটি সঠিক?

  • প্রতিটি ইনপুটে worst case O(log n), AVL-এর মতো
  • শুধু ইনপুট এলোমেলো ক্রমে এলে O(log n), সাজানো ইনপুটে O(n)
  • Amortized O(log n), কিন্তু একক কাজ O(n) পর্যন্ত — splay tree-র মতো
  • যেকোনো ইনপুটে প্রত্যাশিত O(log n); প্রত্যাশা শুধু priority-র এলোমেলোতার ওপর

একটি implicit treap-এ রাখা অ্যারের [l, r] অংশ প্রত্যাশিত O(log n) সময়ে উল্টাতে হবে। কোন পদ্ধতিটি ঠিক?

  • অংশের সব node-এ গিয়ে বাঁ ও ডান সন্তান অদলবদল করা
  • দুবার split করে অংশটি আলাদা করা, তার root-এ একটি lazy “উল্টাও” চিহ্ন বসানো, তারপর দুবার merge
  • অংশটির উপাদানগুলো key অনুযায়ী উল্টো ক্রমে sort করা
  • অংশের node-গুলোর priority উল্টো ক্রমে বসিয়ে দেওয়া

একটি treap বা skip list-এর test suite-এ random জেনারেটরকে নির্দিষ্ট seed দেওয়ার প্রধান কারণ কী?

  • প্রোগ্রাম দ্রুত চলে
  • Worst case আর কখনো ঘটতে পারে না
  • প্রতিবার একই আউটপুট আসে, তাই কোনো বাগ আবার ঘটিয়ে দেখা যায় (reproducibility)
  • নির্দিষ্ট seed ছাড়া treap ভুল উত্তর দিতে পারে

শব্দকোষ

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

মনে রাখুন

  1. Treap = key-তে BST + এলোমেলো priority-তে heap; আকৃতি অনন্য, আর হুবহু একটি random BST-র মতো — ইনপুটের ক্রম যা-ই হোক।
  2. xi হলো xk-এর ancestor সম্ভাবনা 1/(|k − i| + 1)-এ → প্রত্যাশিত গভীরতা < 2 ln n; insert/delete-এ প্রত্যাশিত rotation < ২.
  3. Split ও merge একটি পথ ধরে চলে, প্রত্যাশিত O(log n); sz দিয়ে k-তম key, আর implicit treap দিয়ে sequence-এ ঢোকানো, মোছা, lazy উল্টানো।
  4. Skip list: মুদ্রা ছুড়ে তলা; প্রত্যাশিত O(log n) সময়, প্রায় 2n pointer (p = 1/2); rotation নেই, পরিবর্তন স্থানীয় → Redis sorted set, LevelDB/RocksDB memtable.
  5. “প্রত্যাশিত” ≠ worst case ≠ amortized; পরীক্ষায় নির্দিষ্ট seed (reproducibility), প্রোডাকশনে অনুমান-অযোগ্য seed — কিন্তু লগে লেখা।

পরের পর্বে: নতুন মডিউল — গ্রাফ অ্যালগরিদম। শহরের রাস্তা, বন্ধুত্বের নেটওয়ার্ক আর কম্পিউটারের সংযোগকে vertex আর edge দিয়ে প্রকাশ করা: adjacency list, adjacency matrix আর গ্রাফের মৌলিক পরিভাষা।

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

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