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

list — বদলানো যায় এমন ক্রম

Lists — Python's Mutable Sequence

Taught in Bangla, this lesson explains Python lists: creating, indexing and slicing, adding with append, extend and insert, removing and searching, sort versus sorted with key, nested lists and the [[0]*3]*3 trap, how a dynamic array works and everyday list patterns.

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

  • list আসলে কী — object-এর reference-এর একটি ক্রমিক, বদলানো যায় এমন সারি
  • list তৈরি, index, slicing, slice দিয়ে বদলানো, len আর in
  • যোগ-মোছা-খোঁজার method: append/extend/insert, remove/pop/del/clear, index/count
  • sort বনাম sorted, key ও reverse দিয়ে সাজানো, stable sort, আর x = x.sort() বাগ কেন হয়
  • list-এর ভেতরে list, ভেতরের dynamic array, কোন কাজ কত দ্রুত — আর কখন list নয়, অন্য কিছু

১ · list কী — নম্বর দেওয়া লকারের সারি

এতদিন আপনি একটি ভ্যারিয়েবলে একটি মান রেখেছেন — একজনের নাম, একটি নম্বর। কিন্তু বাস্তবের ডেটা প্রায় কখনো একা আসে না। বাজারের ফর্দে দশটি জিনিস, ক্লাসে ষাট জনের নম্বর, একটি ক্রিকেট দলের এগারো জনের রান, আজকের কাজের তালিকা। প্রত্যেকটির জন্য আলাদা ভ্যারিয়েবল (item1, item2, …) বানানো অসম্ভব, আর ফর্দে একটি জিনিস বাড়লেই কোড বদলাতে হবে।

Python-এর উত্তর হলো list: অনেকগুলো জিনিস একটি নামের নিচে, নির্দিষ্ট ক্রমে। পর্ব ১২-এ for লুপে list দেখেছেন; এবার ভেতরে ঢুকি।

রেলস্টেশনের লকারের সারি ভাবুন। লকারগুলোর গায়ে নম্বর লেখা — ০, ১, ২, ৩… প্রতিটি লকারে মালপত্র নিজে নেই; আছে একটি টোকেন, যাতে লেখা “আপনার ব্যাগ গুদামের ওই তাকে”. সারিতে নতুন লকার জোড়া যায়, পুরোনো লকার খুলে ফেলা যায়, এক লকারের টোকেন বদলে অন্য তাকের ঠিকানা দেওয়া যায়। দুটি লকারের টোকেন একই তাক দেখাতে পারে — তখন তাকের জিনিস বদলালে দুই লকার দিয়েই সেই বদল দেখা যাবে।

এই ছবির প্রতিটি অংশ list-এর একটি সত্য:

🔢 ক্রমিক (ordered)

প্রতিটি ঘরের একটি নম্বর আছে — index, যা শুরু হয় ০ থেকে। যে ক্রমে রাখবেন, সেই ক্রমেই থাকবে। list একটি sequence, ঠিক string-এর মতো।

bazaar[0]

✏️ বদলানো যায় (mutable)

list তৈরির পরেও ঘর যোগ, মোছা, বদলানো যায় — একই object-এর ভেতরে। এজন্য list একটি mutable object; string বা tuple কিন্তু তা নয়।

bazaar.append("ডিম")

🏷️ reference রাখে

ঘরে থাকে একটি reference — কোনো object-এর দিকে তীর। পর্ব ০৬-এর “নাম হলো লেবেল” ভাবনা এখানে প্রতিটি ঘরের জন্য খাটে।

ঘর → object

🧺 যেকোনো টাইপ

যেহেতু ঘরে শুধু তীর, তাই একই list-এ int, string, এমনকি আরেকটি list রাখা যায়: [1, "ক", 2.5, [3]]. পারা যায় — কিন্তু সাধারণত উচিত নয়।

[78, "রহিম"]
মূল ধারণা

list জিনিস রাখে না, জিনিসের ঠিকানা রাখে। এই একটি বাক্য থেকে এই পর্বের প্রায় সব রহস্য ব্যাখ্যা করা যায় — কেন মিশ্র টাইপ রাখা যায়, কেন [[0]*3]*3 অদ্ভুত আচরণ করে, কেন list কপি করলে ভেতরের জিনিস কপি হয় না, আর কেন Python-এর list একই আকারের C array-র চেয়ে বেশি মেমরি নেয়।

মিশ্র টাইপ নিয়ে একটি সৎ পরামর্শ। ["রহিম", 78, True] লেখা যায়, কিন্তু এমন list-এর প্রতিটি ঘরের অর্থ আলাদা — ঘর ০ নাম, ঘর ১ নম্বর, ঘর ২ পাস কি না। এমন “একটি রেকর্ডের কয়েকটি অংশ” রাখার জন্য tuple (পর্ব ১৫), dictionary (পর্ব ১৬) বা class (পর্ব ২৯) বেশি উপযুক্ত। list সবচেয়ে ভালো কাজ করে যখন সব ঘরে একই ধরনের জিনিস থাকে — সব নাম, বা সব নম্বর — যাতে একই কাজ প্রতিটির ওপর লুপ দিয়ে করা যায়।

২ · list তৈরি, index, slicing আর in

list তৈরির চারটি প্রচলিত উপায়:

লেখাফলকখন
[]খালি listলুপে পরে জিনিস যোগ করবেন
["চাল", "ডাল", "তেল"]তিনটি string-এর listজিনিসগুলো আগেই জানা
list("abc")['a', 'b', 'c']যেকোনো iterable-কে list বানাতে
list(range(5))[0, 1, 2, 3, 4]অলস range-কে সত্যিকারের list বানাতে

list() একটি iterable নেয়, তার ওপর হেঁটে প্রতিটি জিনিস নতুন list-এ রাখে। তাই list("ঢাকা") দেয় প্রতিটি Unicode code point-এর আলাদা ঘর — ['ঢ', 'া', 'ক', 'া'], পর্ব ০৯-এ যেমন দেখেছেন।

index আর negative index

পর্ব ০৯-এ string-এর জন্য যা শিখেছেন, list-এ হুবহু একই। bazaar = ["চাল", "ডাল", "তেল", "আলু"] হলে bazaar[0] হলো "চাল", bazaar[3] হলো "আলু". শেষ থেকে গুনতে negative index: bazaar[-1] শেষটি ("আলু"), bazaar[-2] শেষের আগেরটি। সীমার বাইরে গেলে — যেমন bazaar[4] — আসে IndexError.

পার্থক্য একটাই, কিন্তু বড়: string-এর ঘর বদলানো যায় না, list-এর যায়। bazaar[2] = "সরিষার তেল" লিখলে ঘর ২-এর তীর নতুন string-এর দিকে ঘুরে যায়; list-এর বাকি সব যেমন ছিল তেমনই।

slicing — এবং slice দিয়ে বদলানো

slicing-এর নিয়মও string-এর মতো: a[start:stop:step], stop ভেতরে থাকে না। bazaar[1:3] দেয় ["ডাল", "তেল"], bazaar[::-1] উল্টো ক্রমে নতুন list. একটি গুরুত্বপূর্ণ কথা: slicing সবসময় একটি নতুন list বানায় — তাই bazaar[:] পুরো list-এর একটি কপি (অংশ ৮-এ আবার আসবে)।

list-এ একটি নতুন ক্ষমতা আছে যা string-এ নেই — slice-এ মান বসানো। বাঁ দিকে slice লিখলে সেই অংশটি ডান দিকের জিনিসগুলো দিয়ে বদলে যায়, আর দৈর্ঘ্য মিলতেই হবে এমন কোনো কথা নেই:

a = [10, 20, 30, 40, 50]
a[1:3] = [21, 22, 23]     # দুটি ঘরের জায়গায় তিনটি
print(a)                  # [10, 21, 22, 23, 40, 50]
a[1:4] = []               # তিনটি ঘর মুছে ফেলা
print(a)                  # [10, 40, 50]
a[1:1] = [15]             # কিছু না মুছে index 1-এ ঢোকানো
print(a)                  # [10, 15, 40, 50]

slice assignment শক্তিশালী, কিন্তু পড়তে একটু কঠিন। দৈনন্দিন কাজে নিচের নাম-ওয়ালা method-গুলো (insert, del, extend) বেশি পরিষ্কার। slice assignment চিনে রাখুন, যাতে অন্যের কোডে দেখে চমকে না যান।

len আর in

len(bazaar) দেয় ঘরের সংখ্যা — খুব দ্রুত, কারণ list নিজের দৈর্ঘ্য সবসময় মনে রাখে। "ডাল" in bazaar দেয় True বা False — এটি পর্ব ০৮-এর membership operator. কিন্তু in-এর গতি len-এর মতো নয়: Python প্রথম ঘর থেকে একটি একটি করে == দিয়ে মেলায় যতক্ষণ না পায়। দশ লাখ ঘরের list-এ না-থাকা জিনিস খুঁজলে দশ লাখ তুলনা। অংশ ৭-এ এর হিসাব দেখবেন।

৩ · list-এ যোগ করা — append, extend, insert

list বদলানোর কাজগুলো হয় method দিয়ে — list.নাম(...) ধাঁচে। যোগ করার তিনটি method:

  • append(x) — x-কে শেষে একটি নতুন ঘরে রাখে। সবসময় ঠিক একটি ঘর বাড়ে।
  • extend(iterable) — iterable-এর প্রতিটি জিনিসের জন্য শেষে একটি করে ঘর। a += [1, 2]-ও একই কাজ করে।
  • insert(i, x) — x-কে index i-তে ঢোকায়; i থেকে শুরু করে বাকি সবাই এক ঘর ডানে সরে।

নতুনদের সবচেয়ে বেশি গুলিয়ে যায় append আর extend, যখন যোগ করার জিনিসটি নিজেই একটি list. বাজারের ব্যাগে দেখা যাক:

1bag = ["চাল", "ডাল"]2bag.append(["আলু", "ডিম"])3bag.extend(["আলু", "ডিম"])4bag.insert(1, "তেল")5print(len(bag), bag[3])
নাম → object (সবুজ লাইন এইমাত্র চলেছে)
bag
len(bag)
method ফেরত দিল
  1. দুই ঘরের ব্যাগ। ঘর ০-তে “চাল”, ঘর ১-এ “ডাল”. দৈর্ঘ্য ২।
  2. append — পুরো থলেটাই একটি জিনিস। append যা পেয়েছে তা-ই একটি ঘরে রেখেছে। এখানে সে পেয়েছে একটি list, তাই ঘর ২-তে এখন একটি ছোট থলে — ভেতরে আলু আর ডিম। দৈর্ঘ্য বেড়েছে মাত্র ১. ব্যাগে আলু খুঁজলে ("আলু" in bag) পাবেন না, কারণ আলু আছে থলের ভেতরে, ব্যাগের সরাসরি কোনো ঘরে নয়।
  3. extend — থলে খুলে ঢালা। extend iterable-এর ওপর হেঁটে প্রতিটি জিনিস আলাদা ঘরে রাখে। তাই এবার “আলু” আর “ডিম” দুটি আলাদা ঘর পেল, দৈর্ঘ্য বাড়ল ২. সাবধান: bag.extend("ডিম") লিখলে string-এর ওপর হেঁটে ‘ড’, ‘ি’, ‘ম’ তিনটি ঘর হতো!
  4. insert(1, "তেল") — মাঝে ঢোকানো। “তেল” বসল ঘর ১-এ; আগে যারা ঘর ১ থেকে শেষ পর্যন্ত ছিল, সবাই এক ঘর ডানে সরল। ছোট list-এ এতে কিছু যায় আসে না; দশ লাখ ঘরে প্রতিটি insert-এ দশ লাখ সরানো — অংশ ৭ দেখুন।
  5. এখন ঘর ৩ কে? সরে যাওয়ার পর ঘর ৩-এ সেই ছোট থলে, তাই ছাপা হলো ['আলু', 'ডিম']. খেয়াল করুন, তিনটি method-ই None ফেরত দিয়েছে — এরা list-কে বদলায়, নতুন কিছু দেয় না। এই নিয়মটি অংশ ৫-এ একটি বিখ্যাত বাগের কারণ।
মূল ধারণা

append একটি জিনিস যোগ করে, extend অনেকগুলো। প্রশ্ন করুন: “আমি কি থলেটাই রাখতে চাই, নাকি থলের ভেতরের জিনিসগুলো?” থলে হলে append, ভেতরের জিনিস হলে extend.

৪ · মোছা আর খোঁজা — remove, pop, del, clear, index, count

মোছার চারটি উপায়, আর প্রতিটির প্রশ্ন আলাদা — “কোন মান মুছব?” নাকি “কোন ঘর মুছব?”

লেখাকী দিয়ে খোঁজেফেরত দেয়না পেলে
a.remove(x)মান — প্রথম যে ঘরে xNoneValueError
a.pop()শেষ ঘরমুছে ফেলা জিনিসটিখালি list-এ IndexError
a.pop(i)ঘরের নম্বর iমুছে ফেলা জিনিসটিIndexError
del a[i], del a[1:3]ঘরের নম্বর বা slice(এটি statement, কিছু ফেরত নেই)IndexError
a.clear()সব ঘরNone—

কয়েকটি সূক্ষ্ম কথা:

  • remove শুধু প্রথমটি মোছে। [8, 45, 8].remove(8)-এর পর থাকে [45, 8]. সব 8 মুছতে চাইলে নতুন list বানানোই পরিষ্কার উপায় (অংশ ৮)।
  • pop একমাত্র মোছার method যা জিনিসটি হাতে দেয়। to-do list থেকে শেষ কাজটি তুলে নিয়ে করতে চাইলে task = todo.pop() — মোছা আর পাওয়া একসাথে। শেষ থেকে যোগ (append) আর শেষ থেকে তোলা (pop) মিলে list একটি stack-এর মতো কাজ করে — থালার স্তূপ, শেষে রাখা থালা আগে ওঠে।
  • del একটি statement, method নয়। এটি ঘর মোছে, আর পুরো নামও মুছতে পারে (del a — তখন a নামটিই থাকে না, list object নয়)।
  • clear() বনাম a = []: clear একই list object-কে খালি করে; a = [] নামটিকে নতুন একটি খালি list-এ লাগায়, পুরোনোটি অন্য কোনো নামে থাকলে ভরাই থাকে। পর্ব ১৯-এ এই পার্থক্য গুরুত্বপূর্ণ হবে।

খোঁজা: index আর count

a.index(x) দেয় প্রথম যে ঘরে x আছে তার নম্বর; না থাকলে ValueError (string-এর find-এর মতো −1 দেয় না)। তাই নিশ্চিত না হলে আগে if x in a: দেখে নিন। a.count(x) গোনে x কতবার আছে — ক্রিকেট দলের রানের list-এ কতজন শূন্য রানে আউট, এক লাইনে: runs.count(0). দুটিই পুরো list ঘাঁটে, তাই বড় list-এ বারবার ডাকলে ধীর।

remove আর pop গুলিয়ে ফেলবেন না। marks = [10, 20, 30]-এ marks.remove(1) “1 মানটি খোঁজো” — না পেয়ে ValueError. আর marks.pop(1) “ঘর ১-এর জিনিস তোলো” — 20 মুছে ফেরত দেয়। একটি মান দিয়ে খোঁজে, অন্যটি ঘরের নম্বর দিয়ে। পূর্ণসংখ্যার list-এ এই ভুল কোনো সতর্কবার্তা ছাড়াই ভুল জিনিস মুছে দিতে পারে।

৫ · সাজানো — sort বনাম sorted, key আর stable sort

Python-এ সাজানোর দুটি উপায়, আর দুটির পার্থক্য এই পর্বের সবচেয়ে গুরুত্বপূর্ণ শিক্ষাগুলোর একটি:

🔧 a.sort() — জায়গায় সাজানো

list method. একই list object-কে সাজিয়ে দেয় (in-place). কোনো নতুন list হয় না, বাড়তি মেমরি প্রায় লাগে না। ফেরত দেয় None. শুধু list-এ চলে।

marks.sort()

📄 sorted(a) — নতুন সাজানো কপি

built-in function. যেকোনো iterable নেয় (list, tuple, string, range…), একটি নতুন সাজানো list ফেরত দেয়; মূলটি যেমন ছিল তেমনই।

top = sorted(marks)

একইভাবে উল্টানোর জন্যও দুটি: a.reverse() জায়গায় উল্টায় আর None দেয়; reversed(a) কোনো list বানায় না, শুধু পেছন থেকে হাঁটার একটি অলস iterator দেয় (পর্ব ১২)। list হিসেবে চাইলে list(reversed(a)) বা a[::-1].

নিয়মটি: যে method বদলায়, সে None ফেরত দেয়

append, extend, insert, remove, sort, reverse, clear — list-কে বদলানো সব method None ফেরত দেয় (ব্যতিক্রম শুধু pop, কারণ তার কাজই একটি জিনিস হাতে দেওয়া)। এটি ইচ্ছাকৃত নকশা: Python চায় আপনি স্পষ্ট জানুন যে মূল list বদলে গেছে, নতুন কিছু তৈরি হয়নি। কিন্তু এই নিয়ম না জানলে একটি বিখ্যাত বাগ হয় — দেখুন:

1marks = [70, 45, 90]2marks = marks.sort()3print(marks)4marks.append(60)
নাম → object, আর list object-এর অবস্থা
list object
sort() ফেরত দিল
marks →
  1. একটি list, একটি নাম। marks লেবেলটি তিনটি নম্বরের একটি list object-এ লাগানো।
  2. ডান দিক আগে চলে: marks.sort(). assignment-এ Python আগে ডান দিক হিসাব করে। sort list object-টিকে জায়গায় সাজাল — এখন [45, 70, 90]. এ পর্যন্ত সব ঠিক। তারপর sort তার কাজ শেষে ফেরত দিল None.
  3. তারপর বাঁ দিক: marks = None. ফেরত পাওয়া মানটি (None) marks নামে লাগানো হলো। লেবেলটি সাজানো list থেকে খুলে None-এর গায়ে চলে গেল। সাজানো list-টি এখন কোনো নামে ধরা নেই — পর্ব ০৫-এর reference counting অনুযায়ী CPython সেটি সঙ্গে সঙ্গে মুছে দেয়।
  4. print(marks) ছাপে None. সাজানো নম্বরের বদলে “None”. কোনো error নেই — এ কারণেই বাগটি বিপজ্জনক; প্রোগ্রাম কিছুদূর চলে তারপর অন্য কোথাও ভাঙে।
  5. আসল ভাঙন পরে আসে। None-এর কোনো append নেই, তাই AttributeError: 'NoneType' object has no attribute 'append'. এই বার্তা দেখলেই আগে খুঁজুন — কোথাও কি কোনো বদলানো method-এর ফল কোনো নামে রাখা হয়েছে? সমাধান: শুধু marks.sort() লিখুন (ফল রাখবেন না), অথবা নতুন list চাইলে marks = sorted(marks).
বদলাতে চান? a.sort() — ফল রাখবেন না
নতুন চান? b = sorted(a) — মূলটি অক্ষত

key আর reverse — নিজের নিয়মে সাজানো

সংখ্যা ছোট থেকে বড়, string অভিধানের মতো (আসলে Unicode code point-এর ক্রমে, পর্ব ০৮-এর lexicographic order). বড় থেকে ছোট চাইলে reverse=True. কিন্তু ধরুন একটি ক্লাসের রোল কলের list, প্রতিটি জিনিস একটি জোড়া ("রহিম", 78) — নম্বর অনুযায়ী সাজাবেন কীভাবে?

এর জন্য key=. আপনি একটি ফাংশন দেন, যা প্রতিটি জিনিস থেকে “কী দিয়ে তুলনা করব” সেই মানটি বের করে — একে বলে key function. Python প্রতিটি জিনিসের জন্য ফাংশনটি একবার ডাকে, তারপর জিনিসগুলো সেই মান অনুযায়ী সাজায়। একাধিক নিয়ম লাগলে (আগে নম্বর, সমান হলে নাম) key ফাংশন একটি tuple ফেরত দিতে পারে, কারণ tuple-এর তুলনা হয় প্রথম ঘর থেকে, সমান হলে দ্বিতীয় ঘর — ঠিক অভিধানের মতো।

def get_mark(student):
    return student[1]

def mark_then_name(student):
    name, mark = student
    return (-mark, name)        # বেশি নম্বর আগে; সমান হলে নামের ক্রমে

students = [("রহিম", 78), ("সুমি", 91), ("করিম", 78), ("নাদিয়া", 65), ("আরিফ", 91)]

print(sorted(students, key=get_mark, reverse=True))
for pos, (name, mark) in enumerate(sorted(students, key=mark_then_name), start=1):
    print(pos, name, mark)

scores = [45, 12, 88, 30]
result = scores.sort()          # list বদলায়, ফেরত দেয় None
print(result, scores)
print(sorted(scores, reverse=True), scores)

প্রথম লাইনের আউটপুটে শুধু নম্বর দিয়ে সাজানো: সুমি আর আরিফ দুজনেই 91, রহিম আর করিম দুজনেই 78. খেয়াল করুন — সমান নম্বরের দুজন মূল list-এ যে ক্রমে ছিল (সুমি আগে, আরিফ পরে; রহিম আগে, করিম পরে), সাজানোর পরেও সেই ক্রমে আছে। এটাই stable sort, আর Python এর নিশ্চয়তা দেয়, reverse=True দিলেও। তারপর mark_then_name একটি tuple দেয় (-mark, name): নম্বরের আগে বিয়োগ চিহ্ন দেওয়ায় বেশি নম্বর আগে আসে, আর নম্বর সমান হলে নাম তুলনা হয় — তাই ক্রম আরিফ, সুমি, করিম, রহিম, নাদিয়া. শেষ দুই লাইন: scores.sort() ফেরত দিয়েছে None, কিন্তু scores নিজে সেজে গেছে [12, 30, 45, 88]; আর sorted(..., reverse=True) নতুন list [88, 45, 30, 12] দিয়েছে, scores অক্ষত। (key-তে ছোট ফাংশন লেখার আরও সংক্ষিপ্ত উপায় lambda — পর্ব ৩৮-এ।)

Python-এর সাজানোর অ্যালগরিদমের নাম Timsort — বানিয়েছিলেন Tim Peters, ২০০২ সালে, Python 2.3-এর জন্য। বাস্তবের ডেটা প্রায়ই আংশিক সাজানো থাকে (গতকালের তালিকায় আজ কয়েকজন যোগ হলো), আর Timsort সেই আগে থেকে সাজানো টুকরোগুলো খুঁজে কাজে লাগায়; পুরোপুরি সাজানো list-এ এটি মাত্র একবার চোখ বুলিয়েই শেষ করে। পরে Java (৭ নম্বর সংস্করণ থেকে object সাজাতে), Android আর V8 JavaScript engine-ও Timsort নিয়েছে। sorted(), reversed() আর key= প্যারামিটার এসেছিল আরও পরে, Python 2.4-এ (২০০৪)।

৬ · list-এর ভেতরে list — নম্বরের টেবিল আর [[0]*3]*3-এর ফাঁদ

একটি list-এর ঘরে যেকোনো object-এর reference রাখা যায় — আরেকটি list-ও। এভাবে তৈরি হয় nested list, যা দিয়ে টেবিল বা গ্রিড বোঝানো যায়। তিনজন শিক্ষার্থীর তিন বিষয়ের নম্বর:

table = [
    [72, 65, 80],   # রহিম: বাংলা, ইংরেজি, গণিত
    [88, 91, 95],   # সুমি
    [40, 28, 55],   # করিম
]
print(table[1])        # [88, 91, 95] — সুমির পুরো সারি
print(table[1][2])     # 95 — সুমির গণিত
for row in table:
    print(sum(row))    # প্রত্যেকের মোট: 217, 274, 123

table[1][2] পড়ুন বাঁ থেকে ডানে: আগে table[1] — একটি সারি (নিজেই একটি list), তারপর সেই সারির [2]. প্রথম index সারি, দ্বিতীয় index কলাম।

এবার একটি ফাঁদ, যাতে প্রায় সবাই একবার পড়েন — নতুনরা তো বটেই, AI-ও। পর্ব ০৯-এ দেখেছেন "-" * 3 দেয় "---"; list-এও [0] * 3 দেয় [0, 0, 0]. তাহলে ৩×৩ শূন্যের গ্রিড বানাতে [[0] * 3] * 3 লিখলেই তো হয়?

1row = [0] * 32grid = [row] * 33grid[0][0] = 14print(grid)5good = [[0] * 3 for _ in range(3)]
মেমরিতে কয়টি সারি-object, আর কে কোথায় নির্দেশ করে
সারি-object
বাইরের list-এর ঘর
  1. একটি সারি তৈরি। [0] * 3 একটি list বানাল যার তিন ঘরই একই 0 object দেখায়। 0 বদলানো যায় না (int immutable), তাই এতে কোনো সমস্যা নেই। সারিটির নাম দিলাম A.
  2. [row] * 3 — তীর কপি হলো, সারি নয়। * list-এর ভেতরের reference-গুলো তিনবার কপি করে। ফলে বাইরের list-এর তিনটি ঘরই একই সারি A-কে দেখায়। মেমরিতে সারি একটিই! পর্দায় ছাপলে [[0, 0, 0], [0, 0, 0], [0, 0, 0]] — দেখে বোঝার উপায় নেই।
  3. একটি ঘর বদলানো। grid[0][0] = 1 মানে “ঘর০ যে সারিকে দেখায়, তার ঘর০-তে 1 রাখো”. ঘর০ দেখায় A-কে, তাই A বদলাল। কিন্তু ঘর১ আর ঘর২-ও তো A-কেই দেখায়…
  4. তিন সারিতেই 1! একটি বদল, তিন জায়গায় দেখা গেল — কারণ “তিন জায়গা” আসলে একই object. একই object-এর একাধিক নামকে বলে alias; এর পুরো আলোচনা পর্ব ১৯-এ। এখানে মূল শিক্ষা: * দিয়ে list গুণ করলে ভেতরের object কপি হয় না।
  5. সঠিক উপায় — প্রতিবার নতুন সারি। [[0] * 3 for _ in range(3)] লুপের প্রতিটি ঘোরায় [0] * 3 নতুন করে চালায়, তাই তিনটি আলাদা সারি। এই এক লাইনের লেখাটির নাম list comprehension — পর্ব ১৮-এ বিস্তারিত। লুপ দিয়েও করা যায়: খালি list নিয়ে তিনবার grid.append([0] * 3).

নিয়ম: [x] * n নিরাপদ শুধু যখন x বদলানো যায় না — সংখ্যা, string, None. x নিজেই list (বা dict, বা অন্য কোনো mutable object) হলে * ব্যবহার করবেন না; প্রতিটি ঘরের জন্য নতুন object বানান।

৭ · ভেতরের ছবি — dynamic array আর কোন কাজ কত দ্রুত

এতক্ষণ list ব্যবহার করেছেন বাইরে থেকে। এবার দেখা যাক CPython-এর ভেতরে list কীভাবে তৈরি — কারণ এটি জানলে আপনি বুঝবেন কোন কাজ সস্তা, কোনটি দামি।

CPython-এ list হলো একটি dynamic array: মেমরিতে পাশাপাশি রাখা কিছু ঘর, প্রতিটি ঘরে একটি pointer (৬৪-বিট কম্পিউটারে ৮ বাইট), যা কোনো Python object-এর ঠিকানা। list object নিজে মনে রাখে তিনটি জিনিস — ঘরগুলো কোথায় শুরু, এখন কয়টি ঘর ভরা (len), আর মোট কয়টি ঘরের জায়গা বরাদ্দ (capacity). সব ঘর পাশাপাশি আর একই মাপের, তাই ঘর i-এর ঠিকানা সরাসরি হিসাব করা যায়: শুরু + i × 8. এজন্য a[500000] আর a[0] একই সময় নেয়।

কিন্তু পাশাপাশি ঘরের একটি সমস্যা: ঘর ফুরিয়ে গেলে পাশে আরেকটি ঘর জুড়ে দেওয়া যায় না — ওখানে মেমরিতে অন্য কিছু থাকতে পারে। তখন বড় একটি নতুন ব্লক নিতে হয়, সব pointer সেখানে কপি করতে হয়। প্রতিটি append-এ এটা করলে খুব ধীর হতো। তাই CPython চালাকি করে — দরকারের চেয়ে একটু বেশি জায়গা আগেই নিয়ে রাখে। ধাপে ধাপে দেখুন (সংখ্যাগুলো CPython 3.14-এ মাপা; সংস্করণভেদে সামান্য বদলাতে পারে):

1a = []2a.append(10)3a.extend([20, 30, 40])4a.append(50)5a.insert(0, 5)
list object-এর ভেতর (→ মানে object-এর pointer, · মানে খালি ঘর)
len
capacity
ঘরগুলো
এই ধাপের কাজ
  1. খালি list. শুধু list object-এর “মাথা” — len 0, capacity 0, কোনো ঘর নেই। খালি list প্রায় কোনো জায়গা নেয় না।
  2. প্রথম append — একসাথে ৪টি ঘর। একটি ঘর দরকার, কিন্তু CPython নিল ৪টির জায়গা। ঘর ০-তে 10-এর pointer, বাকি তিনটি খালি অপেক্ষায়। লক্ষ করুন — 10 নিজে ঘরে নেই, ঘরে তার ঠিকানা।
  3. আরও তিনটি — বিনা খরচে। জায়গা আগেই নেওয়া ছিল, তাই নতুন ব্লক লাগেনি; শুধু তিনটি pointer লেখা হলো। এখন len = capacity = 4 — সব ঘর ভরা।
  4. ঘর ফুরিয়েছে — বড় ব্লকে স্থানান্তর। পঞ্চম জিনিসের জায়গা নেই। CPython ৮ ঘরের নতুন ব্লক নিল, পুরোনো ৪টি pointer কপি করল, পুরোনো ব্লক ছেড়ে দিল, তারপর 50 লিখল। এই একটি append দামি (৫টি কাজ), কিন্তু এর পরের তিনটি append আবার সস্তা হবে। capacity বাড়ার ধারা CPython 3.14-এ: 0, 4, 8, 16, 24, 32, 40, 52, 64, 76… — প্রতিবার আগের চেয়ে মোটামুটি এক-অষ্টমাংশ বেশি, সাথে ছোট একটি বাড়তি।
  5. insert(0, …) — সবাইকে সরাতে হয়। শুরুতে ঢোকাতে হলে আগের ৫টি pointer-কেই এক ঘর করে ডানে সরাতে হয় — list যত বড়, তত বেশি সরানো। পাঁচ লাইনে মোট ১৫টি কাজ, তার ৬টিই শুধু এই একটি insert-এর জন্য। দশ লাখ ঘরের list-এ একটি insert(0, x) মানে দশ লাখ সরানো; pop(0) আর মাঝখান থেকে remove-ও তাই।

দামি append মাঝে মাঝে আসে, কিন্তু যত বড় list, তত দূরে দূরে আসে — কারণ প্রতিবার ব্লক আনুপাতিক হারে বাড়ে। মোট খরচকে সব append-এর মধ্যে ভাগ করলে প্রতিটির গড় খরচ একটি ধ্রুবক থাকে। একে বলে amortized O(1): “প্রতিটি append সস্তা” নয়, বরং “অনেকগুলো append মিলিয়ে গড়ে সস্তা”. (Big-O ভাবনা পর্ব ০২-এ দেখেছেন।)

কাজখরচকেন
a[i], a[i] = x, len(a)O(1)ঠিকানা সরাসরি হিসাব; দৈর্ঘ্য মনে রাখা থাকে
a.append(x), a.pop()O(1)*শেষে কাজ, কাউকে সরাতে হয় না (* amortized)
a.insert(0, x), a.pop(0)O(n)বাকি সব ঘর এক ঘর সরাতে হয়
x in a, a.index(x), a.count(x), a.remove(x)O(n)একটি একটি করে মেলাতে হয়
a[i:j], a.copy()O(k) / O(n)যতগুলো ঘর, ততগুলো pointer কপি
a.sort(), sorted(a)O(n log n)Timsort; আগে থেকে সাজানো হলে প্রায় O(n)

কখন list নয়

এই টেবিল থেকেই সিদ্ধান্ত আসে। ব্যাংকের লাইনের মতো সারি — পেছনে যোগ, সামনে থেকে তোলা — list দিয়ে করলে প্রতিটি pop(0) পুরো list সরায়। এর জন্য আছে collections.deque (উচ্চারণ “ডেক”), যার দুই প্রান্তেই যোগ-তোলা O(1); পর্ব ২৭-এ দেখবেন। আর বারবার “এটা কি আছে?” জিজ্ঞেস করতে হলে list-এর O(n) in-এর বদলে set (পর্ব ১৭) বা dictionary (পর্ব ১৬), যেখানে খোঁজা গড়ে O(1).

শেষে যোগ-তোলা আর index → list
সামনে থেকে তোলা → deque · বারবার খোঁজা → set / dict

৮ · প্রতিদিনের প্যাটার্ন — লুপে বানানো, ছাঁকা, unpacking আর কপি

বাস্তবের কোডে list নিয়ে কয়েকটি কাজ বারবার ফিরে আসে। বাজারের ফর্দ আর একটি ক্রিকেট দলের রান দিয়ে সবগুলো একসাথে চালিয়ে দেখুন:

bazaar = ["চাল", "ডাল", "তেল"]
bazaar.append("পেঁয়াজ")
bazaar.extend(["আলু", "ডিম"])
bazaar.insert(0, "লবণ")
bazaar.remove("তেল")
last = bazaar.pop()
print(bazaar, "| শেষে তোলা:", last)
print("ডাল" in bazaar, bazaar.index("ডাল"), len(bazaar))

runs = [45, 8, 72, 0, 31, 8, 104, 15]
big = []
for r in runs:                  # ছাঁকা: শুধু ৩০ বা বেশি
    if r >= 30:
        big.append(r)
first, second, *rest = runs     # ওপেনার দুজন, বাকিরা একসাথে
print(big, first, second, rest)
print("মোট", sum(runs), "| সর্বোচ্চ", max(runs), "| শূন্য", runs.count(0))

grid = [[0] * 3] * 3
grid[0][0] = 1
print(grid)

প্রথম অংশে ফর্দটি ধাপে ধাপে বদলেছে: পেঁয়াজ শেষে, আলু-ডিম আলাদা দুটি ঘরে, লবণ একেবারে শুরুতে, তেল মুছে গেছে, আর pop() শেষ জিনিস “ডিম” তুলে last-এ দিয়েছে। তাই ছাপা হলো ['লবণ', 'চাল', 'ডাল', 'পেঁয়াজ', 'আলু'] | শেষে তোলা: ডিম, তারপর True 2 5 — ডাল আছে, ঘর ২-এ, মোট ৫টি জিনিস। দ্বিতীয় অংশে খালি list নিয়ে লুপে শর্ত মেনে append — এটাই “লুপে list বানানো” আর “ছাঁকা” (filtering) প্যাটার্ন, ফল [45, 72, 31, 104]. first, second, *rest = runs হলো unpacking: প্রথম দুটি দুই নামে, তারকাচিহ্নওয়ালা নামে বাকি সব একটি list হয়ে — 45 8 [72, 0, 31, 8, 104, 15]; পর্ব ১৫-এ বিস্তারিত। তারপর sum, max আর count: মোট 283, সর্বোচ্চ 104, শূন্য 1 জন। শেষ লাইনে অংশ ৬-এর ফাঁদ নিজের চোখে: [[1, 0, 0], [1, 0, 0], [1, 0, 0]].

লুপে list বানানো আর ছাঁকা — comprehension-এর ঝলক

“খালি list → লুপ → শর্ত → append” প্যাটার্ন এত বেশি ব্যবহৃত যে Python-এ এর একটি সংক্ষিপ্ত রূপ আছে: big = [r for r in runs if r >= 30]. পড়ুন: “runs-এর প্রতিটি r, যদি r ৩০ বা বেশি হয়”. এটি list comprehension, পর্ব ১৮-এর বিষয়। আপাতত লম্বা রূপটিই লিখুন — আগে প্যাটার্নটি হাতে বুঝুন, তারপর সংক্ষেপ।

enumerate আর built-in

to-do list ক্রমিক নম্বরসহ ছাপতে পর্ব ১২-এর enumerate: for n, task in enumerate(todo, start=1):. আর যোগফল, সর্বোচ্চ, সর্বনিম্নের জন্য লুপ নয়, sum(), max(), min(). খালি list-এ max([]) ValueError দেয় — দরকারে max(runs, default=0) লিখুন।

list কপি করা — অল্প কথায়

b = a লিখলে কোনো কপি হয় না — শুধু একই list-এ দ্বিতীয় একটি লেবেল লাগে; b.append(1) করলে a-তেও দেখা যাবে। সত্যিকারের নতুন list চাইলে তিনটি সমান উপায়: a[:], list(a), a.copy(). তবে এগুলো shallow copy — নতুন list, কিন্তু ঘরগুলোতে একই object-এর তীর। ভেতরের জিনিস mutable হলে (যেমন নম্বরের টেবিলের সারি) কপি আর মূল এখনো সেই সারিগুলো ভাগাভাগি করে। এর পুরো গল্প, আর copy.deepcopy কখন লাগে, পর্ব ১৯-এ।

লুপ চলার সময় সেই list থেকে মুছবেন না। পর্ব ১২-এ স্টেপারে দেখেছেন — for m in marks: if m < 33: marks.remove(m) একটি উপাদান না দেখেই পার হয়ে যায়। নিরাপদ উপায়: যা রাখতে চান তা দিয়ে নতুন list বানান (উপরের “ছাঁকা” প্যাটার্ন), অথবা কপির ওপর লুপ চালান: for m in marks[:]:.

C/C++ জানলে

C-র array-র আকার তৈরির সময়ই স্থির, আর ঘরে মান সরাসরি থাকে; Python list নিজে বাড়ে-কমে আর ঘরে রাখে object-এর pointer. সবচেয়ে কাছের আত্মীয় C++-এর std::vector — সেটিও dynamic array, amortized O(1) push_back, আর শুরুতে insert O(n); তবে vector-এ এক টাইপের মান সরাসরি থাকে, pointer নয়। Python-এর index সবসময় সীমা যাচাই করে (IndexError), C-র মতো চুপচাপ বাইরের মেমরি পড়ে না। বিস্তারিত তুলনার জন্য C++ কোর্সের vector পর্ব আর C কোর্সের array পর্ব দেখুন।

পরীক্ষা ও ইন্টারভিউতে
  • append আর extend-এর পার্থক্য? — append যা পায় তা-ই একটি ঘরে রাখে; extend একটি iterable-এর প্রতিটি জিনিস আলাদা ঘরে যোগ করে।
  • sort() আর sorted()-এর পার্থক্য? — sort() list-কে জায়গায় সাজিয়ে None দেয়; sorted() যেকোনো iterable থেকে নতুন সাজানো list দেয়।
  • remove, pop আর del? — remove মান দিয়ে প্রথমটি মোছে; pop index দিয়ে মুছে জিনিসটি ফেরত দেয়; del index বা slice মোছে, কিছু ফেরত দেয় না।
  • [[0]*3]*3-এ একটি ঘর বদলালে কী হয়? — তিন সারিতেই বদল দেখা যায়, কারণ তিনটি ঘর একই সারি-object-এর reference.
  • list-এ append আর insert(0)-এর time complexity? — append amortized O(1); insert(0) O(n), কারণ বাকি সব ঘর সরাতে হয়।

৯ · AI-এর যুগে — AI-এর লেখা list কোড যাচাই

list নিয়ে AI-এর লেখা কোড সাধারণত চলে, আর ছোট ডেটায় ঠিক উত্তরও দেয়। সমস্যা লুকিয়ে থাকে দুই জায়গায় — চুপচাপ ভুল ফল (None, বাদ পড়া উপাদান), আর বড় ডেটায় হঠাৎ ধীর হয়ে যাওয়া। কয়েকটি পরিচিত ধরন:

AI প্রায়ই যা লেখেসমস্যাঠিক রূপ
names = names.sort()list হারায়, নামে থাকে Nonenames.sort() বা sorted(names)
লুপের ভেতরে একই list-এ removeউপাদান বাদ পড়ে, error ছাড়াইনতুন list বানানো, বা কপির ওপর লুপ
বড় লুপের ভেতরে if x in big_listপ্রতিটি খোঁজা O(n) — মোট O(n²)একবার set(big_list) বানিয়ে তাতে খোঁজা
বারবার queue.pop(0)প্রতিবার পুরো list সরানোcollections.deque আর popleft()
[[0] * cols] * rowsসব সারি একই objectপ্রতিটি সারি নতুন করে বানানো
সমান্তরাল দুই list (ids, names) দিয়ে খোঁজাindex মেলানোর ভুল, ধীর খোঁজাচাবি দিয়ে খুঁজতে dict

O(n²)-এর হিসাবটি একবার দেখে নিন। ১০,০০০ জন গ্রাহকের list-এ ১০,০০০টি লেনদেনের প্রতিটির গ্রাহক আছে কি না in দিয়ে দেখলে, খারাপ ক্ষেত্রে প্রায় ১০ কোটি তুলনা। একই list থেকে একবার set বানালে কাজটি হয় প্রায় ১০,০০০টি খোঁজায়। পরীক্ষার ছোট ডেটায় দুটোই চোখের পলকে শেষ হয় — তাই AI-এর কোড “চলছে” দেখে নিশ্চিন্ত হবেন না।

AI-কে list-সংক্রান্ত কোড লিখতে বা রিভিউ করতে বলার সময় নিয়মগুলো আগেই বলে দিন। নিচের কাঠামোটি কপি করে ব্যবহার করুন:

আমি Python 3.12 বা নতুন version ব্যবহার করি। নিচের কাজের জন্য কোড লিখে দাও: একটি ক্লাসের শিক্ষার্থীদের (নাম, নম্বর) জোড়ার list আছে; নম্বর অনুযায়ী বড় থেকে ছোট সাজাও, সমান নম্বরে নামের ক্রমে; ফেল করা (৩৩-এর কম) শিক্ষার্থীদের বাদ দিয়ে নতুন list দাও; আর আরেকটি নামের list-এর কারা এই ক্লাসে আছে তা বের করো। শর্ত: ১. sort() বা অন্য কোনো বদলানো method-এর ফল কোনো ভ্যারিয়েবলে রাখবে না; নতুন list দরকার হলে sorted() ব্যবহার করো। ২. যে list-এর ওপর লুপ চলছে, লুপের ভেতরে তাকে বদলাবে না। ৩. বড় ডেটায় বারবার "আছে কি না" দেখতে হলে list-এর বদলে set ব্যবহার করো, আর কেন তা এক লাইনে বলো। ৪. প্রতিটি মূল কাজের time complexity (Big-O) কমেন্টে লেখো। ৫. খালি list আর সমান নম্বরের দুজন থাকলে কী হবে, তা কোডের নিচে বলো।

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

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

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

প্র ০১নাম “list” হলেও Python-এর list একটি dynamic array, linked list নয়। Lisp-এর মতো পুরোনো ভাষায় list মানেই ছিল linked list. Python কেন উল্টো পথে গেল?

একটি linked list-এ প্রতিটি জিনিস থাকে আলাদা একটি “নোড”-এ, আর প্রতিটি নোড পরেরটির ঠিকানা রাখে — রেলগাড়ির বগির মতো, একটির সাথে আরেকটি জোড়া। এর বড় সুবিধা: কোনো নোডের হাতে থাকলে তার পরে নতুন নোড ঢোকানো বা মোছা O(1), কাউকে সরাতে হয় না। Lisp (১৯৫০-এর দশকের শেষ ভাগ) এই কাঠামোর ওপরই দাঁড়িয়ে, আর C কোর্সেও আপনি linked list নিজে হাতে বানাতে পারেন।

কিন্তু বাস্তবের প্রোগ্রাম list দিয়ে কী করে, তা দেখলে হিসাব বদলে যায়। সবচেয়ে বেশি হয় index দিয়ে পড়া (a[i]), শুরু থেকে শেষ পর্যন্ত লুপ, আর শেষে যোগ করা। linked list-এ a[500000] পেতে পাঁচ লাখ বগি পার হতে হয় — O(n); dynamic array-তে একটি গুণ-যোগ — O(1). শেষে যোগ দুটোতেই সস্তা (array-তে amortized). আর মাঝখানে ঢোকানো linked list-এ সস্তা ঠিকই, কিন্তু আগে সেই জায়গায় পৌঁছাতে হাঁটতে হয় — তাই বাস্তবে সুবিধাটা প্রায়ই মিলিয়ে যায়।

দ্বিতীয় কারণ আধুনিক হার্ডওয়্যার। CPU মেমরি থেকে একবারে একটি ছোট টুকরো (cache line) নিয়ে আসে। array-র ঘরগুলো পাশাপাশি, তাই একবারে কয়েকটি ঘর cache-এ চলে আসে আর লুপ দ্রুত চলে। linked list-এর নোড মেমরির যেখানে-সেখানে ছড়ানো, প্রতিটি নোডে প্রায় নতুন করে মেমরিতে যেতে হয়। তৃতীয় কারণ মেমরি: প্রতিটি নোডে জিনিসের pointer ছাড়াও পরের (আর দুই-দিকের হলে আগের) নোডের pointer, সাথে নোডের নিজের হিসাব — ঘর-প্রতি খরচ কয়েক গুণ।

Python পুরোপুরি linked list বাদ দেয়নি — যেখানে দুই প্রান্তে যোগ-তোলাই মূল কাজ, সেখানে collections.deque. CPython-এর deque ভেতরে আসলে কয়েকটি ছোট array-ব্লকের একটি দুই-দিকের শিকল — দুই কাঠামোর সুবিধা মিলিয়ে। Java-র ArrayList আর C++-এর std::vector-ও dynamic array, আর দুই ভাষাতেই linked list থাকা সত্ত্বেও সাধারণ কাজে array-টিকেই ডিফল্ট হিসেবে পরামর্শ দেওয়া হয়।

মূল উপলব্ধি: ডেটা স্ট্রাকচার বাছাই হয় তত্ত্বের সবচেয়ে ভালো ক্ষেত্র দেখে নয়, বাস্তবে কোন কাজ সবচেয়ে বেশি হয় আর হার্ডওয়্যার কীভাবে চলে তা দেখে। Python সেই সবচেয়ে প্রচলিত কাজগুলোকে সস্তা করেছে।

প্র ০২Python-এর সাজানো Timsort সবসময় stable — সমান জিনিসের আগের ক্রম বদলায় না। এই নিশ্চয়তা কেন এত গুরুত্বপূর্ণ, আর Timsort কোথা থেকে এল?

২০০২ সালে Tim Peters — Python-এর প্রথম দিকের মূল ডেভেলপারদের একজন, যিনি “The Zen of Python”-এরও লেখক — Python 2.3-এর জন্য list.sort() নতুন করে লেখেন। তাঁর পর্যবেক্ষণ ছিল সহজ: বাস্তবের ডেটা এলোমেলো থাকে না। লগ ফাইল প্রায় সময়ের ক্রমে, একটি তালিকায় কয়েকটি নতুন নাম যোগ হয়, কোনো ডেটা উল্টো ক্রমে আসে। Timsort ডেটার মধ্যে আগে থেকে সাজানো “run” খুঁজে বের করে, ছোট run-কে insertion sort দিয়ে লম্বা করে, তারপর merge sort-এর মতো run-গুলো জোড়া লাগায়। ফল: সবচেয়ে খারাপ ক্ষেত্রেও O(n log n), আর আগে থেকে সাজানো ডেটায় প্রায় O(n).

এর সাথে এসেছিল একটি প্রতিশ্রুতি: সাজানো stable. কেন এটা দরকারি? ধরুন একটি ফলাফলের তালিকা আগে নাম অনুযায়ী সাজানো। এখন নম্বর অনুযায়ী সাজালেন। stable হলে সমান নম্বরের শিক্ষার্থীরা নামের ক্রমেই থাকবেন — অর্থাৎ দুই ধাপে সাজিয়ে দুই-স্তরের ক্রম পাওয়া যায়: আগে গৌণ চাবি (নাম), তারপর মুখ্য চাবি (নম্বর)। স্প্রেডশিট বা ওয়েবসাইটের টেবিলে একটির পর আরেকটি কলামে ক্লিক করে সাজালে মানুষ ঠিক এই আচরণই আশা করেন। stable না হলে প্রতিবার সাজানোর পর সমান জিনিসগুলো এলোমেলো হতে পারত, আর পরীক্ষার ফলাফলে একই নম্বরের দুজনের ক্রম এক রানে এক রকম, আরেক রানে আরেক রকম — যা টেস্ট লেখা আর ফল যাচাই কঠিন করে।

Timsort-এর সাফল্যে অন্যরাও এটি নিয়েছে: Java ৭ নম্বর সংস্করণ থেকে object-এর array সাজাতে, Android, আর ২০১৮ সালে Google-এর V8 JavaScript engine. ২০১৫ সালে একদল গবেষক formal verification (গাণিতিকভাবে প্রমাণ করার যন্ত্র) দিয়ে দেখান যে Java-র Timsort বাস্তবায়নের একটি অভ্যন্তরীণ নিয়ম কিছু বিরল ক্ষেত্রে ভাঙতে পারে; Python-এর কোডেও একই নিয়ম ছিল, যদিও সাধারণ কম্পিউটারে সেই ক্ষেত্র তৈরি করা কার্যত অসম্ভব — তবু সংশোধন করা হয়। পরে Python 3.11-এ run জোড়া লাগানোর নিয়ম বদলে “Powersort” নামের একটি উন্নত কৌশল নেওয়া হয়েছে, কিন্তু stable থাকার প্রতিশ্রুতি একই আছে।

মূল উপলব্ধি: ভালো অ্যালগরিদম বাস্তব ডেটার স্বভাব কাজে লাগায়, আর একটি ছোট নিশ্চয়তা (stability) ব্যবহারকারীর হাতে বড় ক্ষমতা দেয় — বহু-স্তরের সাজানো, আর প্রতিবার একই ফল।

প্র ০৩append মাঝে মাঝে পুরো list কপি করে, তবু একে O(1) বলা হয় কেন? CPython ঠিক কতটা বাড়তি জায়গা নেয়, আর কম বা বেশি নিলে কী হতো?

দুটি চরম ভাবুন। প্রথমটি: প্রতিটি append-এ ঠিক এক ঘর বাড়ানো। তাহলে প্রতিটি append-এ পুরো list কপি — ১, ২, ৩ … n ঘর কপি, মোট প্রায় n²/২। দশ লাখ append-এ প্রায় পাঁচ লাখ কোটি কপি — অচল। দ্বিতীয়টি: প্রতিবার ঘর ফুরালে capacity দ্বিগুণ করা। তাহলে কপি হয় ১, ২, ৪, ৮ … ঘরের — যোগফল সবসময় n-এর দ্বিগুণের কম। n টি append-এ মোট কাজ ৩n-এর মতো, অর্থাৎ append-প্রতি গড়ে একটি ধ্রুবক। এটাই amortized analysis: একটি কাজ মাঝে মাঝে দামি হলেও দীর্ঘ ধারায় তার গড় খরচ দেখা।

মূল কথা হলো — বৃদ্ধি হতে হবে আনুপাতিক (গুণ করে), যোগ করে নয়। প্রতিবার নির্দিষ্ট ১০০ ঘর বাড়ালেও শেষ পর্যন্ত O(n²) হয়। আনুপাতিক হার যত ছোটই হোক (দ্বিগুণ না হয়ে ১.৫ গুণ বা ১.১২৫ গুণ), গড় খরচ ধ্রুবকই থাকে — শুধু ধ্রুবকটি বড় হয়।

CPython বেছে নিয়েছে বেশ ছোট একটি হার। বর্তমান সংস্করণগুলোতে নতুন capacity মোটামুটি “দরকারি আকার + তার এক-অষ্টমাংশ + একটি ছোট ধ্রুবক”, তারপর ৪-এর গুণিতকে গোল করা। এই পর্বের স্টেপারের ধারা — 0, 4, 8, 16, 24, 32, 40, 52, 64, 76 — CPython 3.14 (৬৪-বিট)-এ sys.getsizeof() দিয়ে মাপা; আপনিও খালি list-এ একটি একটি করে append করে আকার বদল দেখতে পারেন। পুরোনো সংস্করণে ধারাটি সামান্য আলাদা ছিল, আর এটি ভাষার নিয়ম নয়, CPython-এর বাস্তবায়নের বিবরণ — তাই এর ওপর নির্ভর করে কোড লিখবেন না। list ছোট হলেও (অনেক pop-এর পর, ভরা ঘর capacity-র অর্ধেকের নিচে নামলে) CPython জায়গা ফেরত দেয়।

ছোট হার কেন? দ্বিগুণ করলে সবচেয়ে খারাপ সময়ে প্রায় অর্ধেক জায়গা খালি পড়ে থাকে; Python-এ কোটি কোটি ছোট list তৈরি হয়, তাই মেমরি বাঁচানো গুরুত্বপূর্ণ। আবার বড় ব্লকে যাওয়ার সময় মেমরি বরাদ্দকারী (realloc) প্রায়ই পুরোনো ব্লকের পাশেই জায়গা বাড়িয়ে দিতে পারে, তখন কপিই লাগে না। C++-এর std::vector-এ হারটি বাস্তবায়নের ওপর নির্ভর করে — প্রচলিত বাস্তবায়নগুলোতে ১.৫ বা ২ গুণ।

মূল উপলব্ধি: “গড়ে সস্তা” আর “প্রতিবার সস্তা” এক নয়। আনুপাতিক বৃদ্ধি দামি কপিগুলোকে এত দূরে দূরে ছড়িয়ে দেয় যে গড় খরচ ধ্রুবক থাকে — আর হারটি বাছাই আসলে মেমরি বনাম গতির একটি আপস।

প্র ০৪sorted(a) নতুন list দেয়, অথচ a.sort() দেয় None — a.sort() সাজানো list ফেরত দিলেই কি সুবিধা হতো না? Python ইচ্ছে করে কেন এমন করল?

Python-এর অফিসিয়াল Design FAQ-তে প্রশ্নটি সরাসরি আছে: “list.sort() কেন সাজানো list ফেরত দেয় না?” উত্তরের সারকথা: যেখানে গতি গুরুত্বপূর্ণ, সেখানে শুধু সাজানোর জন্য list-এর একটি কপি বানানো অপচয়; তাই sort() জায়গায় সাজায়। আর যেহেতু এটি মূল list-কেই বদলায়, সেটা মনে করিয়ে দিতে ইচ্ছা করেই সাজানো list ফেরত দেয় না। নতুন কপি দরকার হলে আছে sorted() (Python 2.4 থেকে)।

ভাবুন যদি sort() নিজেকেই ফেরত দিত। top = marks.sort() লিখে একজন ভাবতেন top একটি নতুন সাজানো কপি, আর marks অক্ষত — অথচ দুটোই একই object, আর মূল ক্রম চিরতরে হারিয়ে গেছে। এই ভুল কোনো error দিত না। বর্তমান নকশায় একই ভুল করলে top হয় None, আর প্রথম ব্যবহারেই প্রোগ্রাম ভাঙে — ভুলটা অন্তত চোখে পড়ে।

এর পেছনের ভাবনাটির একটি নাম আছে: command-query separation, যা Eiffel ভাষার স্রষ্টা Bertrand Meyer জনপ্রিয় করেন। প্রতিটি method হয় একটি “command” (অবস্থা বদলায়, কিছু ফেরত দেয় না), নয়তো একটি “query” (প্রশ্নের উত্তর দেয়, কিছু বদলায় না) — দুটো একসাথে নয়। Python পুরোপুরি এই নীতি মানে না (pop() বদলায় আর ফেরতও দেয়), কিন্তু list-এর বদলানো method-গুলোতে মোটামুটি মেনে চলে। Guido van Rossum একবার python-dev মেইলিং লিস্টে লিখেছিলেন যে তিনি a.sort().reverse() ধাঁচের method শিকল পছন্দ করেন না, কারণ এতে পড়ে বোঝা যায় না কোন object বদলাচ্ছে আর কোনটি নতুন।

অন্য ভাষার অভিজ্ঞতাও এই সিদ্ধান্তের পক্ষে কথা বলে। JavaScript-এ array.sort() মূল array বদলায় এবং সেটিকেই ফেরত দেয় — বহু বছর ধরে এটা বাগের পরিচিত উৎস। শেষে ২০২৩ সালের ECMAScript সংস্করণে toSorted() আর toReversed() যোগ হয়, যারা Python-এর sorted()-এর মতো নতুন array দেয়।

মূল উপলব্ধি: একটি ফাংশন কী ফেরত দেয়, তা-ও একটি বার্তা। “কিছু ফেরত দিই না” মানে “আমি আপনার দেওয়া object-টাই বদলেছি” — Python এই বার্তা দিয়ে একটি পুরো শ্রেণির নীরব বাগকে জোরালো, চোখে পড়া বাগে বদলে দিয়েছে।

প্র ০৫Python list, C array আর C++ std::vector — তিনটিই “ক্রমিক ঘরের সারি”. মেমরিতে এরা কীভাবে আলাদা, আর এই পার্থক্য গতি ও নমনীয়তায় কী প্রভাব ফেলে?

C array সবচেয়ে সরল: int a[1000] মেমরিতে ১০০০টি int পাশাপাশি, প্রতিটি সাধারণত ৪ বাইট — মোট প্রায় ৪ KB, এর বাইরে কোনো হিসাব নেই। আকার তৈরির সময় স্থির; দৈর্ঘ্য array নিজে মনে রাখে না; a[5000] লিখলে কেউ বাধা দেয় না — অন্য কারও মেমরি পড়া বা নষ্ট করা হয় (undefined behavior), যা বহু নিরাপত্তা-ত্রুটির উৎস।

C++ std::vector<int> একই রকম পাশাপাশি int রাখে, কিন্তু নিজে দৈর্ঘ্য আর capacity মনে রাখে, প্রয়োজনে বড় ব্লকে সরে যায় (amortized O(1) push_back), আর কাজ শেষে নিজেই মেমরি ছেড়ে দেয়। v[i] সীমা যাচাই করে না (গতির জন্য), v.at(i) করে। সব ঘর একই টাইপের — compile-এর সময়ই ঠিক করা।

Python list একধাপ বেশি পরোক্ষ: ঘরে মান নয়, প্রতিটি ঘরে একটি ৮-বাইটের pointer, আর প্রতিটি pointer একটি পূর্ণ Python object দেখায় — যার নিজের টাইপ-তথ্য আর reference count আছে। ৬৪-বিট CPython-এ একটি ছোট int object প্রায় ২৮ বাইট। ফলে দশ লাখ আলাদা int-এর list-এ শুধু pointer-এই ৮ MB, সাথে int object-গুলো — C array-র ৪ MB-র কয়েক গুণ। (−5 থেকে 256 পর্যন্ত ছোট int CPython আগেই বানিয়ে রাখে আর ভাগাভাগি করে, তাই সেগুলোর বাড়তি খরচ নেই।) লুপে প্রতিটি ঘর পড়তে আগে pointer, তারপর object — CPU cache-এর জন্যও খারাপ।

এর বদলে Python কী পায়? যেকোনো object রাখার স্বাধীনতা — int, string, ফাংশন, আরেকটি list — একই সারিতে; সব index-এ সীমা যাচাই; আর মেমরি নিয়ে কোনো চিন্তা নেই। যখন গতি আর মেমরি সত্যিই জরুরি — যেমন লাখ লাখ সংখ্যা নিয়ে হিসাব — Python-এও C-র মতো সরাসরি মান রাখার কাঠামো আছে: standard library-র array module, আর আরও শক্তিশালী NumPy array (পর্ব ৪৬), যা ভেতরে একটি C array আর তার ওপর পুরো-array-এর হিসাব C-তে চালায়।

মূল উপলব্ধি: প্রতিটি পরোক্ষ স্তর নমনীয়তা দেয় আর গতি-মেমরি নেয়। C array যন্ত্রের সবচেয়ে কাছে, vector তার ওপর নিরাপদ স্বয়ংক্রিয়তা, Python list সর্বোচ্চ নমনীয়তা — আর ভালো প্রোগ্রামার জানেন কোন কাজে কোন স্তর দরকার।

অনুশীলন

আগে নিজে চেষ্টা করুন, তারপর উত্তর মিলিয়ে নিন।

  1. আউটপুট অনুমান: a = [1, 2, 3]. এরপর (ক) a.append([4, 5]) করলে len(a) কত? (খ) নতুন করে a = [1, 2, 3] নিয়ে a.extend("45") করলে a কী? (গ) a = [1, 2, 3], b = a.reverse() — a আর b কী?

    (ক) 4 — append পুরো [4, 5]-কে একটি ঘরে রাখে: [1, 2, 3, [4, 5]]. (খ) [1, 2, 3, '4', '5'] — extend string-এর ওপর হাঁটে, প্রতিটি অক্ষর একটি string হিসেবে আলাদা ঘরে যায়; সংখ্যা 4, 5 নয়। (গ) a হলো [3, 2, 1] (জায়গায় উল্টানো), আর b হলো None — বদলানো method কিছু ফেরত দেয় না।

  2. to-do list: todo = ["বিল দেওয়া", "বাজার", "ফোন করা"]. হাতে লিখুন — কোন লাইনগুলোর পরে list কেমন হবে: todo.insert(0, "নামাজ"), done = todo.pop(1), todo.remove("ফোন করা"), todo.append("পড়া"). শেষে done কী?

    insert-এর পর ["নামাজ", "বিল দেওয়া", "বাজার", "ফোন করা"]. pop(1) ঘর ১-এর “বিল দেওয়া” তুলে নেয়: ["নামাজ", "বাজার", "ফোন করা"], আর done = "বিল দেওয়া". remove-এর পর ["নামাজ", "বাজার"]; append-এর পর ["নামাজ", "বাজার", "পড়া"]. খেয়াল করুন — insert-এর পর সবার index এক করে বেড়েছে, তাই pop(1) এখন “বাজার” নয়, “বিল দেওয়া” তোলে।

  3. বাগ খুঁজুন: একজন লিখেছেন names = input_names.sort(), তারপর for n in names: print(n). প্রোগ্রাম TypeError: 'NoneType' object is not iterable দিল। কেন? দুটি আলাদা সমাধান দিন — একটিতে input_names বদলাবে, আরেকটিতে বদলাবে না।

    input_names.sort() list-টিকে জায়গায় সাজিয়ে None ফেরত দেয়, তাই names হলো None; আর None-এর ওপর for লুপ চলে না। সমাধান ১ (মূলটি বদলাবে): input_names.sort() আলাদা লাইনে লিখে তারপর for n in input_names:. সমাধান ২ (মূলটি অক্ষত): names = sorted(input_names). কোনটি বেছে নেবেন? মূল ক্রম পরে আর লাগবে না, আর list বড় — তাহলে ১; মূল ক্রম পরে লাগবে বা অন্য কোথাও ব্যবহৃত হচ্ছে — তাহলে ২।

  4. গতির হিসাব (কোড লিখে): ৫০,০০০ জনের একটি রোল-নম্বর list আছে, আর ৫০,০০০টি উপস্থিতির এন্ট্রি। প্রতিটি এন্ট্রির রোল list-এ আছে কি না দেখতে হবে। দুটি উপায় লিখুন — একটিতে সরাসরি list-এ in, আরেকটিতে আগে set বানিয়ে তাতে in. খারাপ ক্ষেত্রে প্রতিটিতে মোটামুটি কতগুলো তুলনা?
    rolls = list(range(50_000))
    entries = list(range(25_000, 75_000))
    
    slow = sum(1 for e in entries if e in rolls)      # প্রতিটি in: O(n)
    roll_set = set(rolls)                              # একবার: O(n)
    fast = sum(1 for e in entries if e in roll_set)   # প্রতিটি in: গড়ে O(1)
    print(slow, fast)                                  # 25000 25000

    প্রথম উপায়ে প্রতিটি এন্ট্রির জন্য list-এ খুঁজতে খারাপ ক্ষেত্রে ৫০,০০০ তুলনা (যারা নেই, তাদের জন্য পুরো list), মোট প্রায় ২৫০ কোটির কাছাকাছি — সাধারণ কম্পিউটারে লক্ষণীয় সময় লাগবে। দ্বিতীয় উপায়ে set বানাতে ৫০,০০০ ধাপ, তারপর প্রতিটি খোঁজা গড়ে একটি ধাপ — মোট প্রায় এক লাখ। উত্তর একই, গতিতে বিশাল পার্থক্য। set কীভাবে এত দ্রুত খোঁজে, তা পর্ব ১৭-এ।

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

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

ক্রমCPython-এ a.append(x) চলার সময় ভেতরে যা ঘটে, তা সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. 🔍 দেখা — ভরা ঘর (len) কি capacity-র সমান?
  2. 📦 সমান হলে মোটামুটি এক-অষ্টমাংশ বেশি ঘরের নতুন ব্লক নেওয়া
  3. 📋 পুরোনো ব্লকের সব pointer নতুন ব্লকে কপি করে পুরোনোটি ছেড়ে দেওয়া
  4. ✍️ প্রথম খালি ঘরে x-এর pointer লেখা
  5. ➕ len এক বাড়ানো, আর None ফেরত দেওয়া

Python-এ a = [1, 2], তারপর a.append([3, 4]). এখন len(a) কত?

  • 4
  • 3
  • 2
  • TypeError

Python-এ x = [3, 1, 2], তারপর x = x.sort(). এরপর print(x) কী ছাপবে?

  • None
  • [1, 2, 3]
  • [3, 1, 2]
  • AttributeError

Python-এ g = [[0] * 2] * 2, তারপর g[0][1] = 7. এখন g কী?

  • [[0, 7], [0, 0]]
  • [[7, 0], [7, 0]]
  • [[0, 0], [0, 0]]
  • [[0, 7], [0, 7]]

CPython-এর list-এ কোন কাজটির খরচ list-এর দৈর্ঘ্যের সাথে বাড়ে (O(n))?

  • a.append(x)
  • a[i]
  • a.insert(0, x)
  • len(a)

Python-এ a = [5, 8, 5, 2]. a.remove(5)-এর পর a কী?

  • [8, 2]
  • [8, 5, 2]
  • IndexError
  • [5, 8, 2]

Python-এ sorted([("ক", 2), ("খ", 1), ("গ", 2)], key=f), যেখানে f প্রতিটি জোড়ার দ্বিতীয় মান ফেরত দেয়। ফল কী?

  • [("খ", 1), ("ক", 2), ("গ", 2)]
  • [("খ", 1), ("গ", 2), ("ক", 2)]
  • [("ক", 2), ("খ", 1), ("গ", 2)]
  • [("ক", 2), ("গ", 2), ("খ", 1)]

শব্দকোষ

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

মনে রাখুন

  1. list হলো reference-এর ক্রমিক, বদলানো যায় এমন সারি — index ০ থেকে, negative index শেষ থেকে, slicing নতুন list দেয়, slice-এ মান বসিয়ে অংশ বদলানো যায়।
  2. append একটি জিনিস, extend অনেকগুলো, insert মাঝে; remove মান দিয়ে, pop index দিয়ে (আর ফেরত দেয়), del index বা slice দিয়ে।
  3. বদলানো method ফেরত দেয় None — x = x.sort() কখনো নয়। নতুন সাজানো list চাইলে sorted(x, key=..., reverse=...); Python-এর সাজানো stable.
  4. [[0]*3]*3 একই সারির তিনটি reference; প্রতিটি সারি আলাদা করে বানান। b = a কপি নয়; a[:], list(a), a.copy() shallow copy.
  5. ভেতরে dynamic array: index আর শেষে যোগ-তোলা O(1), শুরুতে ঢোকানো আর in O(n). সামনে থেকে তোলায় deque, বারবার খোঁজায় set/dict.

পরের পর্বে: list-এর একটি ভাই আছে, যাকে একবার বানালে আর বদলানো যায় না — tuple. কেন “বদলানো যায় না” আসলে একটি সুবিধা, কখন list আর কখন tuple, আর first, *rest = runs-এর মতো unpacking-এর পুরো ক্ষমতা দেখবেন পর্ব ১৫-এ।

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

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