লংগেস্ট ইনক্রিজিং সাবসিকোয়েন্স
এই পাঠে যা শিখবেন
- LIS-এর $O(n^2)$ DP রাজ্য সংজ্ঞা ও রিকারেন্স
- কেন "ইনডেক্স $i$-তে শেষ হওয়া LIS" রাজ্য হিসেবে বেছে নেওয়া হয়, শুধু "প্রথম $i$টি এলিমেন্টের LIS" নয়
- নেইভ এক্সপোনেনশিয়াল রিকার্সন বনাম $O(n^2)$ DP-এর বাস্তব যাচাই
- $O(n \log n)$ পেশেন্স-সর্টিং পদ্ধতির মূল ধারণা (সংক্ষেপে)
১ · সমস্যার সংজ্ঞা
একটি অ্যারে $a[0 \ldots n-1]$ দেওয়া থাকলে, এমন একটি দীর্ঘতম ইনডেক্স-ক্রম $i_1 < i_2 < \cdots < i_k$ খুঁজে বের করতে হবে যেন $a[i_1] < a[i_2] < \cdots < a[i_k]$ (কঠোরভাবে ক্রমবর্ধমান)। আমাদের লক্ষ্য শুধু $k$-এর সর্বোচ্চ মান বের করা। এটি DSA কোর্সে একটি পরিচিত সমস্যা — এখানে আমরা এর $O(n^2)$ DP সমাধানের সঠিকতা প্রমাণ ও বাস্তব যাচাইয়ে মনোযোগ দেব।
২ · DP রিকারেন্স — $O(n^2)$
$dp[i]$ সংজ্ঞায়িত করি এমন LIS-এর দৈর্ঘ্য হিসেবে যা ঠিক ইনডেক্স $i$-তে শেষ হয় (এটাই গুরুত্বপূর্ণ — "প্রথম $i$টি এলিমেন্টের LIS" নয়, বরং "$a[i]$-কে শেষ এলিমেন্ট হিসেবে ব্যবহার করা LIS")।
$$ dp[i] = 1 + \max\big(\{\, dp[j] : 0 \le j < i,\ a[j] < a[i] \,\} \cup \{0\}\big) $$
অর্থাৎ, $a[i]$-এর আগের এমন সব ইনডেক্স $j$ বিবেচনা করা হয় যেখানে $a[j] < a[i]$ (তাই $a[i]$ ঐ সাবসিকোয়েন্সের পরে যোগ করা যায় ক্রম না ভেঙে), এবং সেগুলোর মধ্যে সবচেয়ে লম্বা $dp[j]$-এর সাথে $1$ যোগ করা হয় (নিজেকে গণনা করে)। যদি এমন কোনো $j$ না থাকে (অর্থাৎ $a[i]$-ই সবচেয়ে ছোট বা প্রথম), তাহলে $dp[i] = 1$ ($a[i]$ একাই একটি দৈর্ঘ্য-১ সাবসিকোয়েন্স)। চূড়ান্ত উত্তর $\max_i dp[i]$ (পুরো অ্যারের LIS যেকোনো ইনডেক্সে শেষ হতে পারে)।
অপটিমাল সাবস্ট্রাকচার: যদি $a[i]$-তে শেষ হওয়া একটি অপটিমাল LIS-এর ঠিক আগের এলিমেন্ট $a[j]$ হয় ($j < i$, $a[j] < a[i]$), তাহলে সেই সাবসিকোয়েন্স থেকে $a[i]$ বাদ দিলে যা থাকে সেটি অবশ্যই $a[j]$-তে শেষ হওয়া একটি অপটিমাল LIS হতে হবে — কারণ $a[j]$-তে শেষ হওয়া এর চেয়ে লম্বা কোনো সাবসিকোয়েন্স থাকলে তার শেষে $a[i]$ যোগ করে মূল সমাধানের চেয়ে লম্বা একটি সাবসিকোয়েন্স পাওয়া যেত — যা অপটিমালিটির সাথে সাংঘর্ষিক। আর একই $dp[j]$ মান একাধিক পরবর্তী ইনডেক্স $i$-এর গণনায় পুনরায় ব্যবহৃত হয় — এটাই ওভারল্যাপিং সাবপ্রবলেম।
৩ · যাচাই — নেইভ এক্সপোনেনশিয়াল ব্রুট-ফোর্স বনাম $O(n^2)$ DP
ব্রুট-ফোর্স ভার্সনটি প্রতিটি এলিমেন্টের জন্য "সাবসিকোয়েন্সে রাখা / না রাখা" — এই দুটি সিদ্ধান্ত রিকার্সিভভাবে পরীক্ষা করে (মোট $2^n$ সাবসিকোয়েন্স), শুধু ক্রমবর্ধমান শর্ত বজায় রাখা সিদ্ধান্তগুলোই গ্রহণ করে।
import random
def lis_brute(arr):
# নেইভ এক্সপোনেনশিয়াল রিকার্সন -- সব 2^n সাবসিকোয়েন্স পরীক্ষা করে
n = len(arr)
best = [0]
def rec(i, prev, length):
if i == n:
best[0] = max(best[0], length)
return
rec(i + 1, prev, length) # arr[i] বাদ দেওয়া
if prev is None or arr[i] > prev:
rec(i + 1, arr[i], length + 1) # arr[i] রাখা (শুধু বৈধ হলে)
rec(0, None, 0)
return best[0]
def lis_dp(arr):
# O(n^2) DP -- dp[i] = ইনডেক্স i-তে শেষ হওয়া LIS-এর দৈর্ঘ্য
n = len(arr)
if n == 0:
return 0
dp = [1] * n
for i in range(n):
for j in range(i):
if arr[j] < arr[i] and dp[j] + 1 > dp[i]:
dp[i] = dp[j] + 1
return max(dp)
rng = random.Random(11)
print(f"{'array':>35} | {'brute':>5} | {'dp (n^2)':>8}")
for trial in range(10):
n = rng.randint(1, 14)
arr = [rng.randint(1, 20) for _ in range(n)]
b = lis_brute(arr)
d = lis_dp(arr)
assert b == d, "মিসম্যাচ!"
print(f"{str(arr):>35} | {b:>5} | {d:>8}")
print("\nসব ১০টি র্যান্ডম অ্যারেতে brute-force == DP")
[19, 7, 17, 8, 10, 16, 1, 3]-এ উভয় পদ্ধতিই ৪ দেয় (একটি বৈধ
LIS: $7, 8, 10, 16$), এবং [1, 1]-এর মতো ডুপ্লিকেট-সহ অ্যারেতে উভয়েই ১ দেয় —
কারণ এখানে "কঠোরভাবে ক্রমবর্ধমান" শর্ত (arr[j] < arr[i], সমান নয়) ব্যবহার করা হয়েছে, তাই
সমান মানের দুটি এলিমেন্ট একসাথে একটি বৈধ ক্রমবর্ধমান সাবসিকোয়েন্স গঠন করে না।
৪ · সংক্ষেপে — $O(n \log n)$ পেশেন্স-সর্টিং পদ্ধতি
$O(n^2)$-এর চেয়ে দ্রুত একটি পদ্ধতি আছে যা tails নামে একটি সহায়ক অ্যারে রাখে — tails[k]
হলো এখন পর্যন্ত পাওয়া দৈর্ঘ্য-$(k{+}1)$ ক্রমবর্ধমান সাবসিকোয়েন্সগুলোর মধ্যে সবচেয়ে ছোট সম্ভাব্য শেষ মান।
প্রতিটি নতুন এলিমেন্টের জন্য বাইনারি সার্চ (L17-এ কভার করা bisect) দিয়ে সঠিক অবস্থান বের করে
tails আপডেট করা হয় — চূড়ান্ত LIS দৈর্ঘ্য হলো tails-এর দৈর্ঘ্য। এই কৌশলের সঠিকতা
প্রমাণ এই পাঠের মূল ফোকাসের বাইরে (এটি একটি আলাদা, সূক্ষ্ম ইনভেরিয়েন্ট-ভিত্তিক প্রমাণ দাবি করে), কিন্তু নিচে
একটি কার্যকরী কোড স্কেচ দেখানো হলো যা $O(n^2)$ DP-এর সাথে একই টেস্ট কেসগুলোতে মিলিয়ে দেখা হয়েছে:
import bisect
def lis_nlogn(arr):
tails = []
for x in arr:
pos = bisect.bisect_left(tails, x)
if pos == len(tails):
tails.append(x)
else:
tails[pos] = x
return len(tails)
rng2 = random.Random(11)
mismatches = 0
for trial in range(10):
n = rng2.randint(1, 14)
arr = [rng2.randint(1, 20) for _ in range(n)]
if lis_dp(arr) != lis_nlogn(arr):
mismatches += 1
print("O(n log n) পদ্ধতি O(n^2) DP-এর সাথে মিসম্যাচ পেয়েছে:", mismatches, "বার (১০টির মধ্যে)")
mismatches সবসময় ০ আসে — একই র্যান্ডম সিড (11) দিয়ে
একই ১০টি অ্যারে তৈরি করা হয়েছে, তাই এই ফলাফলটি সরাসরি উপরের প্রধান পরীক্ষার সাথে তুলনাযোগ্য। তবে মনে রাখবেন —
এই পাঠের সম্পূর্ণ, প্রমাণসহ-যাচাইকৃত পদ্ধতি $O(n^2)$ DP-টিই; $O(n \log n)$ ভার্সনটি এখানে একটি বাস্তবে-কাজ-করা
বোনাস হিসেবে দেখানো হয়েছে।
LIS দেখায় কীভাবে DP-এর রাজ্য বাছাই সাবধানে করতে হয় — "$i$-তম এলিমেন্ট পর্যন্ত LIS" (সাধারণ প্রিফিক্স) কাজ করে না, কারণ সেই রাজ্য থেকে পরবর্তী এলিমেন্ট বৈধভাবে যোগ করা যাবে কি না তা জানতে অতিরিক্ত তথ্য (শেষ এলিমেন্টের মান) দরকার হয়। তাই রাজ্যটি "$i$-তে শেষ হওয়া LIS" হিসেবে সংজ্ঞায়িত করা হয়েছে — এই সূক্ষ্ম পার্থক্যটি DP রাজ্য ডিজাইন করার সময় একটি গুরুত্বপূর্ণ সাধারণ শিক্ষা।
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিজে কিছুক্ষণ ভাবুন — তারপর "→ উত্তর" চাপুন।
প্র ০১
যদি অ্যারেটি ইতিমধ্যে সম্পূর্ণ ক্রমবর্ধমান থাকে (যেমন [1,2,3,4,5]), তাহলে $dp$ অ্যারেটি
কেমন দেখাবে?
dp = [1, 2, 3, 4, 5] — কারণ প্রতিটি এলিমেন্ট তার আগের সব এলিমেন্টের চেয়ে বড়, তাই
dp[i] = dp[i-1] + 1 প্রতিটি ধাপে। এটি এই সমস্যার সবচেয়ে সহজ কেস, এবং $O(n^2)$ DP-তেও এটি
স্বাভাবিকভাবেই দ্রুত ধরা পড়ে (যদিও কমপ্লেক্সিটি এখনও $O(n^2)$-ই থাকে, কারণ প্রতিটি $i$-এর জন্য এখনও সব
আগের $j$ পরীক্ষা করা হয়)।
প্র ০২ যদি সমস্যাটি "কঠোরভাবে ক্রমবর্ধমান"-এর বদলে "অ-হ্রাসমান" (non-decreasing, সমান মান অনুমোদিত) করা হতো, তাহলে কোড ও উত্তরে কী পরিবর্তন লাগত?
শুধু তুলনা অপারেটর বদলাতে হতো — lis_dp-তে arr[j] < arr[i]-কে
arr[j] <= arr[i] করতে হতো, এবং ব্রুট-ফোর্সে arr[i] > prev-কে
arr[i] >= prev করতে হতো। তখন [1, 1]-এর উত্তর ১-এর বদলে
২ হতো, কারণ দুটি সমান মান একসাথে একটি বৈধ "অ-হ্রাসমান" সাবসিকোয়েন্স গঠন করবে।
প্র ০৩
$O(n \log n)$ পদ্ধতিতে tails অ্যারেটি কি সবসময় ইনপুট অ্যারের একটি বৈধ সাবসিকোয়েন্স
প্রতিনিধিত্ব করে?
না, চূড়ান্ত tails অ্যারেটি নিজে কোনো একটি নির্দিষ্ট বৈধ LIS নাও হতে পারে — এটি শুধু প্রতিটি
দৈর্ঘ্যের জন্য "সবচেয়ে ভালো (ছোট) সম্ভাব্য শেষ মান" ট্র্যাক করে, যা প্রক্রিয়া চলাকালীন ওভাররাইট হতে থাকে।
এর দৈর্ঘ্যটি সঠিক LIS দৈর্ঘ্যের সমান — এই নিশ্চয়তাটাই এই কৌশলের প্রমাণের মূল বিষয় (যা এই পাঠের
পরিধির বাইরে), কিন্তু প্রকৃত LIS স্ট্রিং পুনর্গঠন করতে অতিরিক্ত বুককিপিং (প্রতিটি এলিমেন্টের পূর্বসূরি
ট্র্যাক করা) প্রয়োজন হয়।
অনুশীলন
-
চিন্তা করুন:
[10, 9, 2, 5, 3, 7, 101, 18]অ্যারেটির LIS দৈর্ঘ্য হাতে-কলমে বের করার চেষ্টা করুন।LIS দৈর্ঘ্য ৪ — একটি বৈধ LIS হলো $2, 5, 7, 101$ (অথবা $2, 3, 7, 101$)। কোনো দৈর্ঘ্য-৫ ক্রমবর্ধমান সাবসিকোয়েন্স নেই।
-
পরীক্ষা করুন: উপরের প্রথম কোড সেলে
testহিসেবেarr = [10, 9, 2, 5, 3, 7, 101, 18]ব্যবহার করেlis_dp(arr)ওlis_brute(arr)কল করে Run চাপুন, ফলাফল আপনার হাতে-গোনা উত্তরের সাথে মিলছে কি না দেখুন।দুটো ফাংশনই ৪ ফেরত দেবে — উপরের অনুশীলনের হাতে-গোনা উত্তরের সাথে মিলে যায়, এবং $O(n \log n)$ ভার্সন
lis_nlogn(arr)-ও একই ৪ দেবে।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- কোর্সের সম্পূর্ণ সিলেবাস দেখুন ৫৭টি পাঠ পরবর্তী পাঠে (L28) আমরা ম্যাট্রিক্স চেইন মাল্টিপ্লিকেশন কভার করব — একটি ইন্টারভাল DP, যেখানে রাজ্য একটি রেঞ্জ $[i, j]$ দিয়ে সংজ্ঞায়িত।
- Data Structures & Algorithms কোর্স সহোদর কোর্স LIS-এর ইমপ্লিমেন্টেশন এবং প্রকৃত সাবসিকোয়েন্স পুনর্গঠন সেই কোর্সে দেখানো হয়েছে।
- সব Courses দেখুন ABCL TECH C, C++, Python, Java, JavaScript, DSA, DBMS, Discrete Mathematics, System Design, Cybersecurity, Cloud Computing & DevOps, Computer Networks, Operating Systems, Computer Architecture, Programming Languages & Compiler Design, Software Engineering & Git, Theory of Computation, Engineering Economics, Full-Stack Web Frameworks, Mobile App Development, Ethics in Computing & AI Safety, Software Testing & Quality Assurance ও Design and Analysis of Algorithms — সব এক জায়গায়।