پایه‌های برنامه‌نویسی پویا: برش میله، زنجیره ماتریس، و اصول اصلی

برنامه‌نویسی پویا مسائل پیچیده را با شکستن آن‌ها به زیرمسئله‌های هم‌پوشان و ذخیره راه‌حل‌ها برای اجتناب از محاسبه زائد حل می‌کند. این راهنمای جامع تکنیک را از طریق مسئله کلاسیک برش میله معرفی می‌کند، آن را به مسئله جزئی‌تر ضرب زنجیره‌ای ماتریس گسترش می‌دهد، و دو ویژگی ضروری — زیرساختار بهینه و زیرمسئله‌های هم‌پوشان — که تعیین می‌کنند چه زمانی برنامه‌نویسی پویا اعمال می‌شود را استخراج می‌کند.

برنامه‌نویسی پویازیرساختار بهینهضرب زنجیره‌ای ماتریس

~8 دقیقه مطالعه · آخرین به‌روزرسانی ۱۶ شهریور ۱۴۰۵

چه چیزی برنامه‌نویسی پویا را از تقسیم‌و‌غلبه متفاوت می‌کند

تقسیم‌و‌غلبه، که پیش‌تر در این مجموعه بحث شد، یک مسئله را به زیرمسئله‌های مستقل که هیچ کاری بین خودشان به اشتراک نمی‌گذارند تجزیه می‌کند. Dynamic Programming (برنامه‌نویسی پویا) برای یک دسته متفاوت از مسائل اعمال می‌شود: مسائلی که زیرمسئله‌هایشان هم‌پوشانی دارند، به این معنا که همان زیرمسئله کوچک‌تر به‌طور مکرر در سراسر شاخه‌های مختلف بازگشت مورد نیاز است. بازگشت ساده این زیرمسئله‌های مشترک را هر بار از صفر دوباره محاسبه می‌کرد، و مقدار عظیمی کار را هدر می‌داد. برنامه‌نویسی پویا هر زیرمسئله متمایز را دقیقاً یک‌بار حل می‌کند و نتیجه را برای استفاده مجدد ذخیره می‌کند.

مسئله برش میله: یک مثال کامل اول

با داشتن یک میله به طول n و یک جدول قیمت‌ها p[i] برای میله‌هایی به طول i، هدف تعیین حداکثر درآمد قابل‌دستیابی با برش میله به قطعات و فروش آن قطعات است.

مثال جدول قیمت:
طول i:     1  2  3  4  5  6  7  8
قیمت p:    1  5  8  9  10 17 17 20

یک میله به طول ۴ می‌تواند به‌طور کامل برای ۹ فروخته شود، به دو قطعه به طول ۲ برای ۵+۵=۱۰ بریده شود، یا به یک قطعه به طول ۱ و یک به طول ۳ برای ۱+۸=۹ بریده شود، و همین‌طور. راه‌حل بهینه برای طول ۴ در نهایت بریدن به دو قطعه به طول ۲ است، که ۱۰ درآمد دارد.

راه‌حل بازگشتی ساده

درآمد یک میله به طول n می‌تواند به‌طور بازگشتی بیان شود: هر برش اول ممکن به طول i را امتحان کن، و به‌طور بازگشتی برای بهترین روش برش n-i باقی‌مانده حل کن.

CUT-ROD(p, n):
  اگر n == 0:
      0 را برگردان
  q = -infinity
  برای i = 1 تا n:
      q = max(q, p[i] + CUT-ROD(p, n - i))
  q را برگردان

این فرمول بازگشتی درست است، اما زمان اجرایش نمایی است: T(n) = Θ(2ⁿ)، چون به‌طور مکرر همان زیرمسئله‌ها را دوباره محاسبه می‌کند. برای مثال، محاسبه CUT-ROD(p, 4) نیازمند محاسبه CUT-ROD(p, 2) چندین بار از طریق مسیرهای بازگشتی مختلف است، و هرکدام از آن‌ها CUT-ROD(p, 1) و CUT-ROD(p, 0) را به‌طور مکرر دوباره محاسبه می‌کنند.

بالا-به-پایین با Memoization

اولین رویکرد برنامه‌نویسی پویا، Memoization، ساختار بازگشتی طبیعی را نگه می‌دارد اما یک جدول برای ذخیره راه‌حل هر زیرمسئله در اولین باری که محاسبه می‌شود اضافه می‌کند، و در درخواست‌های بعدی مقدار کش‌شده را به‌جای محاسبه مجدد برمی‌گرداند.

MEMOIZED-CUT-ROD(p, n):
  فرض کن r[0..n] یک آرایه جدید باشد، مقداردهی‌شده با -infinity
  MEMOIZED-CUT-ROD-AUX(p, n, r) را برگردان

MEMOIZED-CUT-ROD-AUX(p, n, r):
  اگر r[n] ≥ 0:
      r[n] را برگردان         // از قبل محاسبه شده
  اگر n == 0:
      q = 0
  در غیر این صورت:
      q = -infinity
      برای i = 1 تا n:
          q = max(q, p[i] + MEMOIZED-CUT-ROD-AUX(p, n - i, r))
  r[n] = q
  q را برگردان

برنامه‌نویسی پویای پایین-به-بالا

یک رویکرد جایگزین، معمولاً به‌دلیل سادگی‌اش و اغلب فاکتورهای ثابت بهتر ترجیح داده می‌شود، زیرمسئله‌ها را به یک ترتیب خاص — کوچک‌ترین اول — حل می‌کند، طوری که هروقت یک زیرمسئله بزرگ‌تر به یک کوچک‌تر نیاز داشته باشد، از قبل محاسبه شده است.

BOTTOM-UP-CUT-ROD(p, n):
  فرض کن r[0..n] یک آرایه جدید باشد
  r[0] = 0
  برای j = 1 تا n:
      q = -infinity
      برای i = 1 تا j:
          q = max(q, p[i] + r[j - i])
      r[j] = q
  r[n] را برگردان

هم نسخه memoized و هم نسخه پایین-به-بالا در زمان Θ(n²) اجرا می‌شوند، بهبود چشمگیری نسبت به بازگشت ساده نمایی، چون هرکدام از n+1 زیرمسئله دقیقاً یک‌بار حل می‌شود، و هر راه‌حل در بدترین‌حالت O(n) کار نیاز دارد.

یک مثال پیچیده‌تر: ضرب زنجیره‌ای ماتریس

ضرب یک زنجیره از ماتریس‌ها A₁ × A₂ × ... × Aₙ را در نظر بگیرید. ضرب ماتریس، که پیش‌تر در این مجموعه بحث شد، شرکت‌پذیر است، پس نتیجه صرف‌نظر از پرانتزبندی یکسان است، اما تعداد ضرب‌های اسکالر مورد نیاز می‌تواند بسته به ترتیبی که ضرب‌ها انجام می‌شوند به‌طور عظیمی متفاوت باشد.

مثال: A₁ (10×100)، A₂ (100×5)، A₃ (5×50)

پرانتزبندی ((A₁A₂)A₃):
A₁A₂ هزینه 10·100·5 = 5,000 ضرب دارد، نتیجه 10×5 است
(A₁A₂)A₃ هزینه 10·5·50 = 2,500 ضرب دارد
مجموع: 7,500 ضرب

پرانتزبندی (A₁(A₂A₃)):
A₂A₃ هزینه 100·5·50 = 25,000 ضرب دارد، نتیجه 100×50 است
A₁(A₂A₃) هزینه 10·100·50 = 50,000 ضرب دارد
مجموع: 75,000 ضرب

تفاوت یک فاکتور ۱۰ برای فقط سه ماتریس است — شکاف برای زنجیره‌های طولانی‌تر به‌طور چشمگیری بزرگ‌تر می‌شود، که انتخاب پرانتزبندی را برای کارایی حیاتی می‌کند.

تعریف ساختار بازگشتی

فرض کن m[i][j] حداقل تعداد ضرب‌های اسکالر مورد نیاز برای محاسبه حاصل‌ضرب Aᵢ...Aⱼ باشد. ایده بازگشتی کلیدی در نظر گرفتن هر موقعیت ممکن k است که آخرین ضرب (تقسیم "بیرونی‌ترین") می‌تواند در آن رخ دهد.

m[i][j] = 0                                    اگر i == j
m[i][j] = حداقل روی k (i ≤ k < j) از:
            m[i][k] + m[k+1][j] + pᵢ₋₁·pₖ·pⱼ

جایی که pᵢ₋₁, pᵢ ابعاد ماتریس Aᵢ هستند

این رابطه بازگشتی می‌گوید: برای برخی نقطه تقسیم k، ابتدا حاصل‌ضرب بخش چپ Aᵢ...Aₖ را به‌طور بهینه محاسبه کن، سپس حاصل‌ضرب بخش راست Aₖ₊₁...Aⱼ را به‌طور بهینه محاسبه کن، سپس این دو ماتریس حاصل را با هم ضرب کن، و هر kای که مجموع را کمینه کند انتخاب کن.

محاسبه پایین-به-بالا

از آنجا که m[i][j] به زیرمسئله‌های شامل زنجیره‌های کوتاه‌تر بستگی دارد، محاسبه بر اساس طول زنجیره پیش می‌رود، از طول ۱ تا طول کامل زنجیره n.

MATRIX-CHAIN-ORDER(p, n):
  فرض کن m[1..n][1..n] و s[1..n][1..n] جدول‌های جدید باشند
  برای i = 1 تا n:
      m[i][i] = 0
  برای length = 2 تا n:
      برای i = 1 تا n - length + 1:
          j = i + length - 1
          m[i][j] = infinity
          برای k = i تا j - 1:
              cost = m[i][k] + m[k+1][j] + p[i-1]·p[k]·p[j]
              اگر cost < m[i][j]:
                  m[i][j] = cost
                  s[i][j] = k       // نقطه تقسیم بهینه را به‌خاطر بسپار
  m, s را برگردان

جدول s[i][j] نقطه تقسیم بهینه را ثبت می‌کند، که اجازه می‌دهد پرانتزبندی بهینه واقعی بعداً بازسازی شود. این الگوریتم در زمان Θ(n³) اجرا می‌شود: Θ(n²) ورودی در جدول وجود دارد، و هرکدام O(n) زمان برای محاسبه با امتحان هر نقطه تقسیم ممکن می‌گیرد.

دو ویژگی ضروری برای برنامه‌نویسی پویا

هر دو مثال بالا دو ویژگی مشترک دارند که با هم نشان می‌دهند چه زمانی برنامه‌نویسی پویا تکنیک درستی برای یک مسئله بهینه‌سازی مشخص است.

زیرساختار بهینه

یک مسئله Optimal Substructure (زیرساختار بهینه) نشان می‌دهد اگر یک راه‌حل بهینه برای مسئله، درون خودش راه‌حل‌های بهینه برای زیرمسئله‌ها را داشته باشد. در برش میله، بهترین روش برش یک میله به طول n، یک راه‌حل بهینه برای طول باقی‌مانده پس از اولین برش را ترکیب می‌کند. در ضرب زنجیره‌ای ماتریس، پرانتزبندی بهینه کل زنجیره، پرانتزبندی‌های بهینه زیرزنجیره‌های چپ و راست در نقطه تقسیم انتخاب‌شده را ترکیب می‌کند.

اثبات زیرساختار بهینه معمولاً از یک استدلال "برش-و-چسباندن" پیروی می‌کند: فرض کن راه‌حلی وجود دارد که از یک راه‌حل غیربهینه برای یک زیرمسئله استفاده می‌کند، سپس نشان بده جایگزینی آن قطعه با راه‌حل واقعاً بهینه برای زیرمسئله نمی‌تواند راه‌حل کلی را بدتر کند، که فرض بهینه‌بودنش را نقض می‌کند یا نشان می‌دهد راه‌حل بهبودیافته حداقل به‌همان‌خوبی است.

زیرمسئله‌های هم‌پوشان

یک مسئله Overlapping Subproblems (زیرمسئله‌های هم‌پوشان) دارد وقتی یک الگوریتم بازگشتی ساده به‌طور مکرر دقیقاً همان زیرمسئله‌ها را بازدید می‌کند، به‌جای اینکه همیشه زیرمسئله‌های کاملاً جدید تولید کند، همان‌طور که در یک الگوریتم تقسیم‌و‌غلبه معمولی مانند merge sort اتفاق می‌افتد. این دقیقاً چرایی این است که ذخیره راه‌حل‌های زیرمسئله، یا از طریق memoization یا یک جدول پایین-به-بالا، چنین سرعت چشمگیری فراهم می‌کند: بدون زیرمسئله‌های هم‌پوشان، هیچ چیزی برای به‌دست‌آوردن از کش‌کردن نتایج وجود نخواهد داشت.

چرا این دو ویژگی برای تشخیص مسائل جدید اهمیت دارند

وقتی با یک مسئله بهینه‌سازی جدید مواجه می‌شویم، بررسی این دو ویژگی تشخیص استاندارد برای این است که آیا برنامه‌نویسی پویا یک تکنیک مناسب است. اگر یک مسئله زیرساختار بهینه داشته باشد اما زیرمسئله‌هایش هم‌پوشانی نداشته باشند (هر فراخوانی بازگشتی واقعاً زیرمسئله‌های متمایزی تولید می‌کند)، تقسیم‌و‌غلبه، که پیش‌تر در این مجموعه بحث شد، معمولاً رویکرد طبیعی‌تر و به‌همان‌اندازه کارآمدی است. این دقیقاً ترکیب زیرساختار بهینه و هم‌پوشانی قابل‌توجه زیرمسئله است که استراتژی memoization یا جدول‌بندی برنامه‌نویسی پویا را با یک بهبود مجانبی نسبت به بازگشت ساده به‌صرفه می‌کند.

نوشته و پژوهش‌شده توسط دکتر شاهین صیامی

مقالات مرتبط

توضیح طولانی‌ترین زیردنباله مشترک و درخت‌های جستجوی دودویی بهینه

دو مسئله کلاسیک برنامه‌نویسی پویای دیگر، تطبیق‌پذیری این تکنیک را فراتر از بهینه‌سازی عددی نشان می‌دهند: یافتن طولانی‌ترین زیردنباله مشترک بین دو رشته، سنگ‌بنای ابزارهای diff و بیوانفورماتیک، و ساخت یک درخت جستجوی دودویی که هزینه جستجوی مورد انتظار را با فرکانس‌های دسترسی شناخته‌شده کمینه می‌کند. این راهنمای جامع هر دو الگوریتم را با جزئیات کامل، شامل استخراج رابطه بازگشتی، ساخت جدول، و بازسازی راه‌حل، مرور می‌کند.

ادامه

درخت‌های قرمز-سیاه: چگونه درخت‌های جستجوی خودمتوازن‌کننده ارتفاع لگاریتمی را تضمین می‌کنند

درخت جستجوی دودویی ساده که پیش‌تر در این مجموعه پوشش داده شد، می‌تواند تحت ترتیب‌های درج بدشانس به ارتفاع خطی تنزل یابد. درخت‌های قرمز-سیاه این را با حفظ پنج ناوردای ساده که از نظر ریاضی ارتفاع لگاریتمی را صرف‌نظر از ترتیب درج تضمین می‌کنند حل می‌کنند. این راهنمای جامع ویژگی‌های قرمز-سیاه، عملیات چرخش که ساختار درخت-جستجو را حین بازساختاردهی درخت حفظ می‌کند، و اینکه درج و حذف چگونه با منطق تعادل‌مجدد گسترش می‌یابند تا این تضمین‌ها را حفظ کنند را پوشش می‌دهد.

ادامه

درخت‌های جستجوی دودویی: پرس‌وجو، درج، و حذف کارآمد

یک درخت جستجوی دودویی عناصر را به ترتیب مرتب‌شده نگه می‌دارد در حالی که جستجو، درج، و حذف کارآمد را پشتیبانی می‌کند، همگی در زمانی متناسب با ارتفاع درخت. این راهنمای جامع ویژگی تعریف‌کننده درخت-جستجوی-دودویی، عملیات‌های اصلی پرس‌وجو شامل جستجو، حداقل، حداکثر، و جانشین، و رویه‌های جزئی‌تر درج و حذف که باید ساختار درخت را با دقت حفظ کنند را پوشش می‌دهد.

ادامه

جداول هش توضیح داده شده: از آدرس‌دهی مستقیم تا آدرس‌دهی باز

جداول هش جستجو، درج، و حذف زمان-ثابت مورد انتظار فراهم می‌کنند، که آن‌ها را به یکی از پراستفاده‌ترین ساختارهای داده در عمل تبدیل می‌کند. این راهنمای جامع ایده آدرس‌دهی مستقیم که هشینگ را انگیزه می‌دهد، نحوه مدیریت برخوردها از طریق زنجیره‌بندی، ویژگی‌های توابع هش خوب، آدرس‌دهی باز به‌عنوان یک جایگزین کارآمد از نظر حافظه، و ملاحظات عملی برای پیاده‌سازی‌های جدول هش دنیای واقعی را پوشش می‌دهد.

ادامه

ساختارهای داده ابتدایی: پشته‌ها، صف‌ها، لیست‌های پیوندی، و درخت‌ها

پیش از پرداختن به ساختارهای داده پیشرفته، تسلط بر بلوک‌های سازنده ابتدایی ضروری است، چون تقریباً هر ساختار پیچیده‌ای از این پایه‌ها ساخته می‌شود. این راهنمای جامع پشته‌ها و صف‌های مبتنی بر آرایه، لیست‌های پیوندی تکی و دوگانه، و تکنیک‌های استاندارد نمایش درخت‌های ریشه‌دار را پوشش می‌دهد، شامل نمایش ظریف فرزند-چپ راهبر-راست برای درخت‌هایی با شاخه‌بندی نامحدود.

ادامه

یافتن میانه بدون مرتب‌سازی کامل: الگوریتم‌های انتخاب زمان-خطی

یافتن k-امین کوچک‌ترین عنصر در یک آرایه مرتب‌نشده نیازمند هزینه کامل Θ(n log n) مرتب‌سازی نیست؛ می‌تواند در زمان خطی انجام شود. این راهنمای جامع مورد بدیهی یافتن حداقل یا حداکثر، یک الگوریتم انتخاب تصادفی ظریف با زمان مورد انتظار خطی، و یک الگوریتم قطعی پیچیده‌تر که زمان خطی را حتی در بدترین‌حالت تضمین می‌کند را پوشش می‌دهد.

ادامه