بنیاد — الگوریتم، ساختار داده و پیچیدگی، با معیار

فصل ۳ از ۱۰

پیشرفت ترم
۰٪

ترم ۱ · «سریع» یعنی چه: اول اندازه بگیر، بعد پیش‌بینی کن

نویز: چرا همان کد دو بارِ پشتِ‌هم دو عدد می‌دهد

فصل ۳پیش‌نمایش رایگان

در این فصل چه یاد می‌گیری#

یک کدِ ثابت را روی یک دادهٔ ثابت سی بار می‌سنجیم. پراکندگیِ عددهای خام ۲۹٫۱ درصد است. همان سی عدد، وقتی از پروتکلِ فصلِ قبل رد شوند، ۱٫۹ درصد.

بعد کارِ خطرناک‌تری می‌کنیم: دو تابعِ حرف به حرف یکسان را با دو اسمِ متفاوت بیست دور با هم مقایسه می‌کنیم. یکی از آن دو در بعضی دورها بیش از ده درصد برنده می‌شود. هیچ تفاوتی بینشان نیست؛ آن برد کاملاً ساختگی است.

ترازویی با دو کفهٔ دقیقاً هم‌وزن که عقربه‌اش زیرِ لرزشِ میز تکان می‌خورد

آخر این فصل می‌توانی:

  • پراکندگیِ اندازه‌گیریِ خودت را با یک عدد گزارش کنی
  • آستانهٔ باور بسازی: تفاوتی که از پراکندگی کوچک‌تر است، تفاوت نیست
  • به‌جای یک عدد، یک بازه گزارش کنی
  • خطایی را تشخیص بدهی که هیچ پیامی ندارد: اندازه‌گیریِ ورودی‌ای که بارِ اول مصرف شده

قبل از شروع#

از فصلِ ۲: پروتکلِ گرم‌کننده، تکرار و کمینه، و تابعِ clock. همان تابعِ first_duplicate را باز هم می‌سازیم تا نوت‌بوک مستقل باشد.

اجرا زمانِ تقریبی
CPU (پیش‌فرضِ Colab) کمتر از یک دقیقه

📓 نوت‌بوک: نوت‌بوک این فصل را در Colab باز کن — همهٔ کدهای این فصل آماده و به‌ترتیب داخلش هست.

۱. سی اندازه‌گیری از یک چیز#

🤔 اول حدس بزن: یک تابعِ ثابت روی یک دادهٔ ثابت، سی بار. بنویس فاصلهٔ کمینه تا بیشینه چند درصد می‌شود: پنج؟ ده؟ سی؟

rng = random.Random(SEED)
ids = rng.sample(range(20_000), 2_000)


def first_duplicate(values):
    """اولین مقداری که دو بار آمده؛ اگر تکراری نبود، None."""
    for i in range(len(values)):
        for j in range(i + 1, len(values)):
            if values[i] == values[j]:
                return values[i]
    return None


first_duplicate(ids)                     # گرم‌کننده، شمرده نمی‌شود
runs = []
for _ in range(30):
    start = time.perf_counter()
    first_duplicate(ids)
    runs.append(1000 * (time.perf_counter() - start))

for row in range(0, 30, 6):
    print("  ".join(f"{v:>6.1f}" for v in runs[row:row + 6]))
print(f"\nکمینه {min(runs):.1f} ms · بیشینه {max(runs):.1f} ms"
      f" · پراکندگی {100 * (max(runs) - min(runs)) / min(runs):.1f} درصد")
  71.8    72.1    69.8    66.9    69.2    69.9
  68.3    68.1    70.1    67.9    67.6    67.1
  67.8    67.8    67.5    67.1    66.5    67.6
  75.7    75.0    72.3    74.9    70.9    69.9
  69.8    69.9    74.6    80.7    82.1    73.3

کمینه 66.5 ms · بیشینه 82.1 ms · پراکندگی 23.4 درصد

سی عدد، از یک کد، روی یک داده، با گرم‌کننده‌ای که قبلش زده شده. و باز هم بیشینه یک‌چهارم بیشتر از کمینه است.

نکتهٔ مهم این است که عددها بی‌نظم پخش نشده‌اند: بیشترشان دورِ هم جمع‌اند و چند تای انگشت‌شمار بالا پریده‌اند. آن چند تا لحظه‌هایی هستند که سیستم‌عامل CPU را به کارِ دیگری داده. در Colab این بدتر است، چون ماشین با کاربرانِ دیگری هم شریک است و تو نمی‌دانی همسایه‌ات چه می‌کند.

۲. همان سی عدد، این بار ثابت#

برای اینکه بشود روی عددها حساب کرد، همان سی اندازه‌گیری را یک بار گرفته‌ایم و در کد نوشته‌ایم. این بخش در هر اجرا دقیقاً همین جواب را می‌دهد.

import statistics

SAMPLE = [64.8, 65.6, 67.6, 73.5, 65.3, 67.2, 69.1, 65.0, 64.2, 77.7,
          74.0, 76.0, 75.0, 69.5, 64.8, 65.3, 68.1, 68.8, 70.2, 70.2,
          67.1, 65.1, 76.2, 82.9, 72.4, 66.1, 67.0, 65.4, 67.6, 69.4]


def spread(values):
    """پراکندگیِ نسبی: فاصلهٔ بیشینه تا کمینه، بر حسبِ درصدِ کمینه."""
    return 100 * (max(values) - min(values)) / min(values)


groups = [SAMPLE[i:i + 5] for i in range(0, 30, 5)]
protocol = [min(g) for g in groups]

print(f"سی اندازه‌گیریِ خام — کمینه {min(SAMPLE):.1f} · میانه"
      f" {statistics.median(SAMPLE):.1f} · بیشینه {max(SAMPLE):.1f}")
print(f"پراکندگیِ خام                        : {spread(SAMPLE):.1f} درصد")
print(f"شش گزارشِ پروتکل (کمینهٔ هر پنج‌تایی): "
      + " ".join(f"{v:.1f}" for v in protocol))
print(f"پراکندگیِ پروتکل                     : {spread(protocol):.1f} درصد")
سی اندازه‌گیریِ خام — کمینه 64.2 · میانه 67.8 · بیشینه 82.9
پراکندگیِ خام                        : 29.1 درصد
شش گزارشِ پروتکل (کمینهٔ هر پنج‌تایی): 64.8 64.2 64.8 65.3 65.1 65.4
پراکندگیِ پروتکل                     : 1.9 درصد

۲۹٫۱ به ۱٫۹. و دقت کن که هیچ اندازه‌گیریِ تازه‌ای انجام نشد — همان سی عدد، فقط پنج‌تا پنج‌تا گروه شدند و از هر گروه کمینه برداشته شد. کاری که پروتکل می‌کند دقیقاً همین است: از هر دستهٔ اندازه‌گیری، کم‌آلوده‌ترینش را نگه می‌دارد.

🌱 ریشه‌اش کجاست: تابعِ spread ما ساده‌ترین سنجهٔ پراکندگی است — فاصلهٔ کمینه تا بیشینه. چرا اصلاً پراکندگی لازم است و چرا یک عددِ «وسط» به‌تنهایی گمراه می‌کند، در ریشه ترمِ ۶ فصل ۶ با دو گروهِ هم‌میانگین ساخته می‌شود که یکی‌شان فشرده است و دیگری پخش. آن فصل سنجهٔ دقیق‌تری هم می‌سازد — فاصلهٔ متوسط از میانگین — که ما اینجا لازمش نداریم، چون سؤالِ ما «بدترین اختلافِ دو گزارش چقدر است؟» است، نه «داده چه شکلی است؟».

چک کن: جای گروه‌بندی را عوض کن — به‌جای پنج‌تایی، سه‌تایی یا ده‌تایی گروه کن. پراکندگیِ پروتکل باید با بزرگ‌تر شدنِ گروه کمتر شود. اگر نشد، احتمالاً min را روی کلِ فهرست گرفته‌ای نه روی هر گروه.

۳. آستانهٔ باور#

عددِ ۱٫۹ درصد فقط یک آمار نیست؛ یک ابزارِ تصمیم است.

قاعده: تفاوتی که از پراکندگیِ خودِ اندازه‌گیریِ تو کوچک‌تر است، تفاوت نیست.

با پروتکلِ این دوره، پراکندگیِ گزارش‌ها حدودِ ۲ درصد درآمد. پس اگر نسخهٔ تازه‌ات ۱٫۵ درصد بهتر شد، هیچ چیزی نمی‌دانی؛ ممکن است بهتر شده باشد، ممکن است بدتر. و با یک اجرای تنها — روشِ فصلِ اول — این آستانه به حدودِ ۲۹ درصد می‌پرد؛ یعنی هر بهبودِ کمتر از یک‌چهارم بی‌معناست.

این قاعده بعداً هم برمی‌گردد: در ترمِ ۶ همین دوره، فصلی هست که می‌گوید کِی باید دست از بهینه‌سازی کشید، و یکی از سه معیارش دقیقاً همین است.

۴. دو نسخهٔ یکسان، یک برندهٔ ساختگی#

حالا خطرناک‌ترین آزمایشِ این فصل. دو تابع می‌سازیم که حرف به حرف یکی‌اند و فقط اسمشان فرق دارد، و بیست دور با هم مقایسه‌شان می‌کنیم.

A = first_duplicate


def B(values):
    for i in range(len(values)):
        for j in range(i + 1, len(values)):
            if values[i] == values[j]:
                return values[i]
    return None


A(ids)
B(ids)                                   # گرم‌کنندهٔ هر دو نسخه
wins = 0
gaps = []
for _ in range(20):
    start = time.perf_counter()
    A(ids)
    ta = time.perf_counter() - start
    start = time.perf_counter()
    B(ids)
    tb = time.perf_counter() - start
    gaps.append(100 * (ta - tb) / ta)
    wins += tb < ta
print(f"دورهایی که B سریع‌تر به‌نظر رسید: {wins} از ۲۰")
print(f"بزرگ‌ترین بردِ ساختگیِ B        : {max(gaps):>5.1f} درصد")
print(f"بزرگ‌ترین بردِ ساختگیِ A        : {-min(gaps):>5.1f} درصد")
دورهایی که B سریع‌تر به‌نظر رسید: 6 از ۲۰
بزرگ‌ترین بردِ ساختگیِ B        :  15.7 درصد
بزرگ‌ترین بردِ ساختگیِ A        :  14.4 درصد

دو تابعِ یکسان، و در بعضی دورها یکی از دیگری بیش از ده درصد جلو می‌زند.

حالا تصور کن این دو تابع واقعاً فرق داشتند و تو فقط یک دور گرفته بودی. آن یک عدد را در گزارشت می‌نوشتی و کسی هم نمی‌توانست ردش کند. بیشترِ «بهینه‌سازی‌ها»یی که در اینترنت با یک عددِ تکی اثبات می‌شوند، از همین جنس‌اند.

📏 اندازه بگیر: چه چیزی با چه چیزی مقایسه شد؟ دو تابعِ یکسان با اسم‌های متفاوت، روی یک ورودیِ ثابت. در کدام بازهٔ n؟ فقط یک اندازه، n = 2000 — و همین کافی است، چون ادعای این آزمایش دربارهٔ رشد نیست، دربارهٔ نویز است. با چند تکرار، و کمینه یا میانگین؟ بیست دور، و اینجا عمداً هیچ خلاصه‌ای نگرفتیم: کلِ حرف این است که تک‌تکِ دورها چه می‌گویند.

۵. خطایی که هیچ پیامی ندارد#

بدترین خطای اندازه‌گیری آنی است که خطا نمی‌دهد. مثالِ زیر یک عددِ کاملاً بی‌معنا تولید می‌کند و هیچ‌جا اعتراض نمی‌شود.

DATA = list(range(200_000))


def drain(stack):
    """تا وقتی چیزی در ظرف هست، از انتها برش می‌دارد و جمع می‌زند."""
    total = 0
    while stack:
        total += stack.pop()
    return total


stack = list(DATA)
wrong = clock(lambda: drain(stack))
copy_cost = clock(lambda: list(DATA))
both = clock(lambda: drain(list(DATA)))
print(f"ورودی یک بار مصرف شد   : {1000 * wrong:>9.4f} ms")
print(f"فقط ساختِ کپیِ تازه     : {1000 * copy_cost:>9.4f} ms")
print(f"کپی + drain            : {1000 * both:>9.4f} ms")
print(f"خودِ drain (با تفریق)   : {1000 * (both - copy_cost):>9.4f} ms")
print(f"عددِ اول چند برابر کوچک‌تر بود؟ {(both - copy_cost) / wrong:>10,.0f}")
ورودی یک بار مصرف شد   :    0.0001 ms
فقط ساختِ کپیِ تازه     :    1.5748 ms
کپی + drain            :   10.7474 ms
خودِ drain (با تفریق)   :    9.1726 ms
عددِ اول چند برابر کوچک‌تر بود؟     91,725

ده‌ها هزار برابر خطا، بدونِ یک خط پیام.

اتفاقی که افتاد ساده است: drain ظرفش را خالی می‌کند. اجرای گرم‌کننده فهرست را تهی کرد و بعد هر پنج اجرای شمرده‌شده روی یک فهرستِ خالی انجام شد — که تقریباً هیچ کاری نیست.

قاعده‌ای که از این درمی‌آید و در کلِ دوره برقرار است: هر اندازه‌گیری باید از حالتِ سرد شروع شود. اگر کدی که می‌سنجی ورودی‌اش را عوض می‌کند، هر اجرا باید ورودیِ تازهٔ خودش را داشته باشد.

و راهِ درستش را هم دیدی: یک بار «کپی + کار» را بسنج، یک بار «فقط کپی»، و تفریق کن. عدد دقیق نیست ولی صادق است — و مهم‌تر اینکه می‌دانی چه چیزی را از آن کم کرده‌ای.

🔧 اگر کار نکرد: اگر جایی زمانی گرفتی که «خیلی خوب» بود — نزدیکِ صفر، یا صدها برابر بهتر از انتظار — قبل از هر شادی، ورودی را بعد از اندازه‌گیری چاپ کن. print(len(stack)) بعد از clock عددِ 0 می‌دهد و کلِ ماجرا را لو می‌دهد. همین یک خط، پرمصرف‌ترین ابزارِ اشکال‌زداییِ benchmark است.

🤖 از دستیارت بپرس: «چرا در Colab زمانِ اجرای یک کدِ ثابت بینِ دو اجرا فرق می‌کند؟» بعد این را هم بپرس: «اگر بخواهم مطمئن شوم تفاوتِ دو نسخه واقعی است، ساده‌ترین کاری که می‌توانم بکنم چیست؟» — یکی از جواب‌های درست این است که ترتیبِ اندازه‌گیریِ دو نسخه را چند بار عوض کنی، چون اگر برد همیشه به اولی یا همیشه به دومی برسد، آن برد مالِ ترتیب است نه مالِ کد.

واژه‌های تازهٔ این فصل#

کلمه تلفظ به حروف فارسی یعنی چه
noise نویز نوسانِ اندازه‌گیری که از کد نمی‌آید، از محیط می‌آید
spread اسپرد فاصلهٔ کمینه تا بیشینهٔ یک دسته اندازه‌گیری
cold start کلد استارت شروعِ اندازه‌گیری از حالتی که هیچ اثری از اجرای قبلی در آن نمانده

تمرین‌ها

اول خودت فکر کن یا امتحان کن — بعد اینجا را باز کن.

در فصل بعد#

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

به آخر این فصل رسیدی!

اگر ساختی و جواب داد، این دکمه مال توست.