Data Structures & Algorithms · ساختمانداده و الگوریتم پایهBeginner ~36 دقیقه مطالعه~30 min read
نماد Big-O و تحلیل پیچیدگیBig-O & Complexity Analysis
از صفر یاد میگیری Big-O واقعاً چه چیزی را میسنجد، تفاوت O/Θ/Ω و میانگین/سرشکن، قضیهی اصلی، و پیچیدگی واقعی کالکشنهای جاوا را — همراه با دامهای پنهانی که مهندسان ارشد را در مصاحبه زمین میزند.Learn from scratch what Big-O actually measures, the difference between O/Θ/Ω and average/amortized, the Master theorem, and the real cost of Java's collections — plus the hidden traps that trip up senior engineers in interviews.
بیا صادق باشیم: بیشتر ما «Big-O» را مثل یک وردِ جادویی حفظ کردهایم — «HashMap برابر O(1)»، «مرتبسازی برابر O(n log n)» — بدون اینکه واقعاً حس کنیم زیرش چه میگذرد. در این درس میخواهیم آن حسِ درونی را بسازیم. قرار است هر واژه را از صفر باز کنیم تا وقتی سرِ مصاحبه مینشینی، نه فقط جواب درست را بدهی، بلکه بدانی چرا درست است.
در این فصل این مسیر را طی میکنیم:
- Big-O واقعاً چه میسنجد — و چرا کرنومتر نیست.
- سه نماد O، Θ، Ω — سقف، کف، و کرانِ تنگ.
- بهترین/میانگین/بدترین حالت و تفاوت ظریفش با سرشکن (amortized).
- چرا
ArrayList.addبرابر O(1) است با اینکه گاهی همه چیز را کپی میکند. - قضیهی اصلی (Master theorem) برای الگوریتمهای بازگشتی.
- جدول پیچیدگی کالکشنهای JDK که باید حفظ باشی.
- رویهی تخمین در مصاحبه و دامهای پنهانی که درجهدو (quadratic) را مخفی میکنند.
- پیچیدگی فضا و یک بخش کامل سوالات مصاحبه.
بخش ۰ — واژههایی که اول باید بشناسی
قبل از هر چیز، سه واژهی پایه که در کل درس تکرار میشوند:
- ورودی
n: اندازهی چیزی که به الگوریتم میدهی. طول یک لیست، تعداد کاربران، تعداد کاراکترهای یک رشته.nقهرمان همهی داستانهای ماست. - مجانبی (asymptotic): صفتی که یعنی «وقتی
nبه سمت بینهایت میرود». تصور کن با یک دوربین از خیلی دور به نمودار رشدِ الگوریتم نگاه میکنی؛ جزئیاتِ نزدیک محو میشوند و فقط شکل کلی رشد میماند. Big-O همان نگاهِ از دور است. - ضریب ثابت (constant factor): عددی که به رشد نمیچسبد. اگر الگوریتمی
5nعملیات کند و دیگری100n، هر دو «خطی» (O(n)) هستند؛ آن ۵ و ۱۰۰ ضریب ثابتاند و Big-O عمداً آنها را نادیده میگیرد.
دو شرکت پیک را تصور کن. شرکت الف برای هر بسته ۳۰ دقیقه وقت میگذارد اما زمانش هر چه سفارش بیشتر شود دو برابر میشود. شرکت ب برای هر بسته ۲ ساعت وقت میگذارد اما زمانش با تعداد سفارشها ثابت میماند. برای ۳ بسته، شرکت الف برنده است. اما برای ۱۰٬۰۰۰ بسته، شرکت ب راحت جلو میزند. Big-O دقیقاً همین را به تو میگوید: نه اینکه الان چه کسی سریعتر است، بلکه اینکه وقتی بار زیاد شود چه کسی برنده میشود.
Big-O دقیقاً چه چیزی را میسنجد
اولین سوءتفاهمِ همه این است که فکر میکنند Big-O یک کرنومتر است که میگوید «این کد ۲ میلیثانیه طول میکشد». نه. Big-O میسنجد که هزینهی منابع یک الگوریتم چگونه با بزرگشدن ورودی n به سمت بینهایت رشد میکند، و ضرایب ثابت و جملات مرتبهپایین را نادیده میگیرد.
آن «منبع» معمولاً زمان است (تعداد عملیات پایه) یا فضا (حافظهی اضافی)، اما میتواند هر چیزی باشد: فراخوانیهای I/O، رفتوبرگشت شبکه (network round-trips)، تعداد مقایسهها، یا حتی cache miss (وقتی دادهای که CPU میخواهد در حافظهی نزدیکِ سریع نیست و باید از حافظهی دور و کند بیاید).
مهمترین جملهای که باید در ذهنت حک شود:
Big-O نرخ رشد (growth rate) را توصیف میکند، نه سرعت مطلق را. یک الگوریتم O(n) میتواند برای ورودیهایی که واقعاً اجرا میکنی کندتر از یک الگوریتم O(n²) باشد — تا زمانی که n بهقدر کافی بزرگ شود. ضرایب ثابت و نقطهی تقاطع (crossover point) در محیط production اهمیت دارند؛ رفتار مجانبی فقط میگوید نهایتاً چه کسی برنده است.
بگذار این را با یک نمودار ببینی. محور عمودی هزینه است و افقی n. هر خم یک کلاس پیچیدگی است:
هزینه
^ O(2^n) O(n^2)
| / /
| / /
| / / O(n log n)
| / / /
| / / / O(n)
| / / / ______/
| / / /____/______________ O(log n)
| //_/_______________________ O(1)
+----------------------------------------------------> n
ببین چطور O(2^n) و O(n²) مثل موشک بالا میروند، در حالی که O(log n) و O(1) تقریباً صاف میمانند. این تصویر را حفظ کن؛ شهودِ کل فصل در همین است.
و این ترتیب کلاسهای رایج، از بهترین (کندترین رشد) به بدترین (تندترین رشد):
O(1) < O(log n) < O(√n) < O(n) < O(n log n) < O(n²) < O(n³) < O(2ⁿ) < O(n!)
O(1) یعنی «مهم نیست ورودی چقدر بزرگ شود، هزینه ثابت است» — مثل خواندن اولین صفحهی یک کتاب. O(log n) یعنی «ورودی هزار برابر شود، هزینه فقط چند برابر میشود» — مثل پیدا کردن یک کلمه در دیکشنری با ورقزدنِ نصفنصف. O(n) یعنی «هزینه همپای ورودی رشد میکند» — خواندن کل کتاب. و O(n²) یعنی «هر عنصر را با هر عنصر دیگر مقایسه میکنی» — مثل دستدادنِ همه با همه در یک مهمانی.
سه نماد: O و Θ و Ω
همه بهطور محاورهای میگویند «Big-O»، اما در واقع سه کرانِ متمایز وجود دارد. دانستنِ فرقشان یک سیگنالِ سطحارشد است.
O مثل تابلوی «حداکثر سرعت ۱۲۰» است: به تو میگوید هرگز تندتر از این نمیروی (کرانِ بالا). Ω مثل تابلوی «حداقل سرعت ۶۰» است: هرگز کندتر از این نمیروی (کرانِ پایین). و Θ وقتی است که هر دو تابلو یک عدد را نشان دهند: میدانی دقیقاً با همان سرعت میرانی (کرانِ تنگ). یک ماشین میتواند «حداکثر ۱۲۰» را رعایت کند در حالی که با ۲۰ میرود — کرانِ بالا درست است اما شل و بیفایده.
| نماد | نام | معنی | نوع کران |
|---|---|---|---|
O(f) |
Big-O | سریعتر از f رشد نمیکند |
کران بالا (upper bound) |
Ω(f) |
Big-Omega | کندتر از f رشد نمیکند |
کران پایین (lower bound) |
Θ(f) |
Big-Theta | دقیقاً مثل f رشد میکند |
کران تنگ (هر دو) |
حالا نسخهی دقیق و ریاضی، که ترسناک بهنظر میرسد ولی ساده است. بهطور صوری، g(n) = O(f(n)) اگر و تنها اگر ثابتهای c > 0 و n₀ وجود داشته باشند بهطوریکه برای همهی n ≥ n₀ داشته باشیم g(n) ≤ c·f(n). یعنی: «از یک جایی به بعد (n₀)، اگر f را در یک عددِ ثابت ضرب کنم، همیشه بالای g میماند.» برای Θ باید g بین دو کران محصور (sandwiched) شود: c₁·f(n) ≤ g(n) ≤ c₂·f(n).
O یک کرانِ بالا است، پس از نظر فنی درست است که بگویی جستوجوی خطی O(n²) است — چون هرگز سریعتر از n² رشد نمیکند. این درست اما بیفایده است، دقیقاً مثل گفتن «این ماشین حداکثر ۱۰۰۰ کیلومتر بر ساعت میرود». تو تنگترین (tightest) کرانِ درست را میخواهی که Θ(n) است. وقتی مصاحبهکننده میپرسد «پیچیدگی چیست»، کرانِ تنگ را میخواهد. وقتی میپرسد «بدترین حالت»، پاسخ یک Big-O روی بدترین ورودی است.
یک ظرافتِ آخر که خیلیها قاطی میکنند: O، Θ، Ω دربارهی رشد هستند و کاملاً مستقل (عمود) از مفهومِ بهترین/میانگین/بدترین حالت (case). یعنی میتوانی پیچیدگیِ بدترینحالت را هم تنگ (Θ) و هم شل (O) بیان کنی. گفتن «QuickSort برابر O(n log n) است» بیدقت است چون بدترین حالتش Θ(n²) است؛ اما «QuickSort در بدترین حالت O(n²) و در حالت میانگین Θ(n log n) است» دقیق و حرفهای است.
بهترین، میانگین، و بدترین حالت
«حالت» یعنی کدام ورودی را داری اندازه میگیری. سه تا مهماند:
مسیرِ خانه تا محل کارت را در نظر بگیر. یک روزِ بدترین حالت یعنی روزی که تصادف و باران و ترافیک با هم جمع شوند — دیرترین حالت ممکن. میانگین حالت یعنی زمانی که بهطور معمول در طول یک ماه میگذری. بهترین حالت یعنی آن صبحِ رؤیایی که تمام چراغها سبز است. برای قولدادن به رئیس («هر روز سرِ ساعت ۹ میرسم») باید حول بدترین حالت برنامه بریزی، نه بهترین.
- بدترین حالت (worst case): ورودیای که هزینه را بیشینه میکند. این چیزی است که SLA (تعهدِ سطح سرویس) را حولش طراحی میکنی، چون کرانِ tail latency (تأخیرِ بدترین درصدِ درخواستها) را میدهد.
getدر HashMap در بدترین حالتO(n)است اگر همهی کلیدها در یک bucket برخورد (collision) کنند. - میانگین حالت (average case): هزینهی موردانتظار روی یک توزیع از ورودیها.
getدر HashMap بهطور میانگینO(1)است. QuickSort بهطور میانگینΘ(n log n)است. - بهترین حالت (best case): خوششانسترین ورودی. مرتبسازی درجی (insertion sort) روی دادهی از قبل مرتب برابر
O(n)است. بهندرت برای برنامهریزی مفید است، اما گاهی برای بهینهسازی به کار میآید (خروجِ زودهنگام / early-exit).
اینجا جایی است که خیلیها میلغزند. میانگین حالت احتمالاتی است روی ورودیها — فرض میکند ورودیها با یک توزیع خاص میآیند. سرشکن (amortized) یک تضمین است که روی یک دنباله از عملیات میانگین گرفته شده، بدون هیچ احتمالی. حتی یک دشمن هم که بدترین ورودی ممکن را انتخاب کند نمیتواند تضمینِ سرشکن را بشکند. این دو را با هم اشتباه نگیر؛ در بخش بعد سرشکن را کامل باز میکنیم.
تحلیل سرشکن: چرا ArrayList.add برابر O(1) است
اینجا یک معماست. پشتِ ArrayList یک آرایهی ساده قرار دارد. وقتی آرایه پر میشود، add مجبور است یک آرایهی بزرگتر تخصیص دهد و همهی عناصر را کپی کند — که آشکارا یک عملیاتِ O(n) است. پس چطور جرأت میکنیم بگوییم add برابر O(1) است؟
تصور کن هر روز صبح دوش میگیری. ۹۹ روز از ۱۰۰ روز، فقط شیر را باز میکنی و آبِ گرم میآید — کارِ ارزان و فوری. اما یک روز آبگرمکن خراب میشود و باید کلِ آن را عوض کنی — کارِ گران. اگر هزینهی آن یک روزِ گران را روی کلِ ۱۰۰ روز پخش کنی، هزینهی «سرشکنِ» هر دوش هنوز ناچیز است. سرشکن یعنی همین: هزینهی عملیاتِ گرانِ نادر را روی همهی عملیاتِ ارزان پخش میکنی.
چرا این پخشکردن جواب میدهد؟ چون resizeهای گران نادر هستند و هرچه پیش میرود نادرتر میشوند. ArrayList با ضریبِ حدوداً ۱٫۵ رشد میکند — در کدِ JDK دقیقاً newCap = oldCap + (oldCap >> 1) (یعنی ظرفیت قدیم بهعلاوهی نصفش). این رشدِ ضربی یعنی برای رسیدن به اندازهی n فقط حدود log(n) بار resize میکنی، و کلِ کارِ کپی در همهی resizeها یک سریِ هندسی (geometric) است که به O(n) جمع میشود، نه O(n²).
بیایید عددی ببینیم. فرض کن آرایه دوبرابر میشود (ظرفیتهای ۱، ۲، ۴، ۸، ...):
append در آرایهی دوبرابرشونده (ظرفیتهای ۱،۲،۴،۸...):
کپیها بهازای هر resize: 1 1 2 4 8 ... n/2
مجموع کپیها = 1 + 2 + 4 + ... + n/2 < n (هندسی)
=> کل کار برای n درج = O(n) => هر add برابر O(1) سرشکن
جادوی سریِ هندسی این است که 1 + 2 + 4 + ... + n/2 همیشه کمتر از n میشود، نه بیشتر. پس کلِ هزینهی n بار append برابر O(n) است. تقسیم بر n عملیات → O(1) سرشکن بهازای هر add. هر add منفرد ممکن است O(n) باشد، اما هیچ دنبالهای از n بار add نمیتواند رویِهم بیش از O(n) هزینه داشته باشد.
ابزارِ صوریِ این استدلال روش حسابداری (accounting method) (یا روش پتانسیل) است: به عملیاتِ ارزان بیشازحد شارژ میکنی تا «اعتبار» بانک کنی که بعداً هزینهی عملیاتِ گرانِ نادر را بپردازد. هر add ارزان ۱ واحد برای خودش میپردازد بهعلاوهی حدود ۲ واحد اعتبار؛ وقتی resize رخ میدهد، اعتبارِ بانکشده کپیها را پوشش میدهد. مثلِ همان آبگرمکن: هر ماه کمی پول کنار میگذاری تا روزِ خرابی، صندوق آماده باشد.
این نکتهی حیاتی است. اگر ArrayList با مقدارِ ثابت رشد میکرد (مثلاً هر بار +۱۰)، آنگاه n/10 بار resize میکردی، و هر کپی بهطور میانگین n/2 عنصر بود، پس کلِ هزینه O(n²) میشد و add به O(n) سرشکن سقوط میکرد. فقط رشدِ ضربی (×۱٫۵ یا ×۲) هزینههای کپی را به یک سریِ هندسیِ کوچک تبدیل میکند. به همین دلیل وقتی اندازه را از قبل میدانی، new ArrayList<>(expectedSize) را صدا بزن — همهی resizeها را یکجا رد میکنی.
همین منطقِ سرشکن زیربنای HashMap است (put سرشکنِ O(1)، که هنگام resize، rehash میکند)، و همینطور StringBuilder و ArrayDeque.
// ضدالگو: اگر لیست خیلی بزرگ شود، resizeهای مکرر را مجبور میکند.
List<Integer> a = new ArrayList<>(); // خالی شروع میشود، در صورت نیاز رشد میکند
for (int i = 0; i < 1_000_000; i++) a.add(i); // در کل ~O(n) سرشکن، ~۲۰ بار resize
// بهتر وقتی اندازه معلوم است: صفر resize، یک تخصیص.
List<Integer> b = new ArrayList<>(1_000_000);
for (int i = 0; i < 1_000_000; i++) b.add(i);
بازگشتیها و قضیهی اصلی (Master theorem)
الگوریتمهای بازگشتی و تقسیموحل (divide-and-conquer) یک نوع معادلهی خاص تولید میکنند بهنام بازگشتی (recurrence) — که هزینهی اندازهی n را بر حسبِ هزینهی زیرمسئلههای کوچکتر بیان میکند:
T(n) = a·T(n/b) + f(n)
که در آن a = تعداد زیرمسئلهها، b = ضریبی که مسئله با آن کوچک میشود، و f(n) = کارِ لازم برای تقسیم و ترکیب.
یک مدیر یک پروژهی بزرگ (n) را میگیرد. آن را به a تکه میشکند و هر تکه را به یک زیرمدیر میدهد، جایی که هر تکه b برابر کوچکتر است (n/b). خودِ مدیر هم کمی کار میکند: تقسیمکردنِ کار و بعد جمعکردنِ نتایج — این همان f(n) است. سوال این است: بارِ اصلی کجاست؟ روی شانهی مدیرانِ ردیفِ بالا (مرحلهی ترکیب) یا روی هزارها کارمندِ ردیفِ آخر (برگها)؟ قضیهی اصلی دقیقاً همین را تعیین میکند.
قضیهی اصلی کارِ برگها (n^(log_b a)) را با f(n) مقایسه میکند. فرض کن c = log_b(a) (این «کارِ برگها» است).
| حالت | شرط | نتیجه | شهود |
|---|---|---|---|
| ۱ | f(n) = O(n^(c-ε)) |
Θ(n^c) |
برگها غالباند |
| ۲ | f(n) = Θ(n^c · logᵏn) |
Θ(n^c · logᵏ⁺¹n) |
متعادل، هر سطح یکسان هزینه دارد |
| ۳ | f(n) = Ω(n^(c+ε)) (+ شرط regularity) |
Θ(f(n)) |
ریشه/ترکیب غالب است |
(آن ε یک عددِ مثبتِ کوچک است؛ صرفاً یعنی «بهطورِ چندجملهای کوچکتر/بزرگتر».) حالا چهار مثالِ حلشده که باید فوری بشناسی:
- مرتبسازی ادغامی (merge sort):
T(n) = 2T(n/2) + O(n). اینجاa=2, b=2, c=log₂2=1، وf(n)=n=Θ(n¹)→ حالت ۲ →Θ(n log n). هر سطحO(n)کارِ ادغام انجام میدهد؛ وlog nسطح وجود دارد. - جستوجوی دودویی (binary search):
T(n) = T(n/2) + O(1).a=1, b=2, c=0،f=Θ(1)=Θ(n⁰)→ حالت ۲ →Θ(log n). - پیمایش بازگشتی که به دو نیم میشود با ترکیبِ ثابت:
T(n)=2T(n/2)+O(1).c=1،f=O(n^(1-ε))→ حالت ۱ →Θ(n)(مثلاً شمارشِ اندازهی یک درخت). - ضربِ کاراتسوبا (Karatsuba):
T(n)=3T(n/2)+O(n).c=log₂3≈۱٫۵۸۵،f=O(n^(c-ε))→ حالت ۱ →Θ(n^1.585).
هیوریستیکِ سریع بدون قضیه: درختِ بازگشت را بکش، کارِ هر سطح را حساب کن، سطوح را جمع بزن. اگر کارِ هر سطح برابر و log n سطح باشد → n log n. اگر کار رو به پایین دوبرابر شود → غالبِ برگها. اگر رو به پایین نصف شود → غالبِ ریشه. این تصویرِ ذهنی هرگز از یادت نمیرود، برخلافِ سه ردیفِ جدول.
قضیهی اصلی کاربرد ندارد وقتی زیرمسئلهها اندازهی نابرابر دارند (مثلاً T(n)=T(n/3)+T(2n/3)+n)، یا وقتی a/b ثابت نیستند. آنجا از روشِ درختِ بازگشت یا قضیهی Akra–Bazzi استفاده کن.
پیچیدگیِ عملیاتِ رایجِ JDK
هر کالکشن مثل یک ابزار در جعبهابزار است. HashMap مثل چکش است — سریع و همهکاره برای «این کلید کجاست؟». TreeMap مثل یک متر است — کندتر، اما ترتیب و بازه به تو میدهد. ArrayDeque مثل انبرِ دوسر است — از هر دو طرف سریع. یک مهندسِ خوب ابزار را با کار جور میکند، نه اینکه همه چیز را با چکش بکوبد.
این جدول را حفظ کن — مصاحبهکنندهها مستقیم آن را میپرسند و تصمیماتِ طراحیِ واقعی را هدایت میکند.
| ساختار | دسترسی/Get | جستوجو/Contains | درج | حذف | نکات |
|---|---|---|---|---|---|
ArrayList |
O(1) اندیس |
O(n) |
O(1)* انتها، O(n) وسط |
O(n) |
*سرشکن در انتها؛ shift در عملیات وسط |
LinkedList |
O(n) |
O(n) |
O(1) در گره معلوم |
O(1) در گره معلوم |
پیمایش تا رسیدن به گره O(n) است |
HashMap |
O(1) میانگین / O(log n) بدترین |
همان | O(1) میانگین |
O(1) میانگین |
بدترین O(n) بود؛ bucketهای درختی → O(log n) از Java 8 |
LinkedHashMap |
O(1) میانگین |
O(1) میانگین |
O(1) میانگین |
O(1) میانگین |
ترتیب درج/دسترسی را نگه میدارد |
TreeMap |
O(log n) |
O(log n) |
O(log n) |
O(log n) |
درخت قرمز-سیاه، مرتب، floor/ceiling/subMap میدهد |
PriorityQueue |
O(1) peek |
O(n) contains |
O(log n) offer |
O(log n) poll |
هرم دودویی؛ remove(Object) برابر O(n) |
ArrayDeque |
O(1) انتها |
O(n) |
O(1)* انتها |
O(1)* انتها |
بهعنوان stack/queue سریعتر از LinkedList |
HashSet |
— | O(1) میانگین |
O(1) میانگین |
O(1) میانگین |
پشت آن HashMap است |
TreeSet |
— | O(log n) |
O(log n) |
O(log n) |
مرتب، navigable |
حالا ظرافتهای کلیدی که یک ارشد باید بداند، یکییکی:
همه فکر میکنند «LinkedList درجِ O(1) دارد پس بهتر است» — این یک تله است. آن درجِ O(1) فقط زمانی کمک میکند که از قبل گره را در دست داری (از طریقِ ListIterator). ولی get(i) برابر O(n) است چون باید از سرِ لیست بشماری تا برسی. بدتر: هر گره یک شیءِ جداگانه در حافظه است، پس محلیتِ کش (cache locality — اینکه دادههای مرتبط کنارِ هم در حافظه باشند تا CPU سریع بخواندشان) نابود میشود. نتیجه اینکه حتی پیمایشِ O(n) هم در LinkedList بسیار کندتر از ArrayList است. برای stack/queue از ArrayDeque استفاده کن.
اینجا یک بهبودِ زیبا در Java 8 رخ داد. وقتی یک bucket از ۸ ورودی فراتر رود و جدول حداقل ۶۴ bucket داشته باشد، آن bucket از یک لیستِ پیوندی به یک درختِ قرمز-سیاه تبدیل میشود («treeify»). این کارِ جستوجویِ بدترینحالت را از O(n) به O(log n) کران میدهد. چرا مهم است؟ چون در برابرِ حملهی DoS دفاع میکند: مهاجم میتوانست عمداً کلیدهایی بسازد که همه در یک bucket برخورد کنند و سرور را با یک bucketِ خطیِ O(n) فلج کند. وقتی bucket دوباره زیرِ ۶ عنصر کوچک شود، به لیست برمیگردد.
PriorityQueue.remove(Object)وcontainsبرابرO(n)هستند — چون یک هرم فقط در مسیرهای ریشهبهبرگ مرتب است، نه سراسری. پس یافتنِ یک عنصرِ دلخواه یعنی پویشِ خطیِ کلِ آرایه. یک «decrease-key» سادهلوحانه روی آن نساز.TreeMapدر ازای کندی، ترتیب میخرد —firstKey،floorKey،ceilingKey،headMap،subMap، و پویشِ بازهای، همهO(log n). اگر به پرسوجوی بازهای یا پیمایشِ مرتب نیاز داری، این تبادلِO(log n)در برابرِO(1)ارزشش را دارد.HashMapهیچ ترتیبی به تو نمیدهد.- هزینهی Stream/پیمایش: پیمایشِ هرکدام
O(n)است، اما ترتیبِ پیمایشِHashMapنامشخص است و هزینهاش متناسب با ظرفیت (capacity) است، نه اندازه — یک map بزرگ و عمدتاً خالی، کند پیمایش میشود.
تخمینِ پیچیدگی در مصاحبه
اینجا یک رویهی تکرارپذیر که میتوانی مثلِ یک چکلیست دنبال کنی:
۱. متغیرهای اندازه را مشخص کن — اغلب بیش از یکی وجود دارد (n = طولِ لیست، m = تعدادِ پرسوجو، k = اندازهی الفبا/انشعاب). با همهشان پاسخ بده: O(n·m)، نه یک حرفِ تنها.
۲. حلقههای تودرتو ضرب میشوند؛ حلقههای متوالی جمع میشوند. دو پیمایشِ مستقلِ پشتِسرِهم = O(n) + O(n) = O(n). حلقهی داخلِ حلقه روی همان n = O(n²).
۳. مراقبِ حلقههایی باش که n بار اجرا نمیشوند. for (i=1; i<n; i*=2) فقط log n بار اجرا میشود، چون i هر بار دوبرابر میشود. هر حلقهای که شمارندهاش نصف یا دوبرابر شود، لگاریتمی است.
۴. بازگشت → بازگشتی → قضیهی اصلی (یا درخت را بشمار).
۵. کارِ پنهانِ درونِ بدنهی حلقه را حساب کن — یک .contains() روی لیست، یک substring، یک sort، یک rehash. بیشترِ پاسخهای اشتباه دقیقاً از اینجا میآید (دامها را پایین ببین).
۶. در پایان ثابتها و جملاتِ غیرغالب را حذف کن: O(3n² + 5n + 100) → O(n²).
روی سختافزارِ معمول، حدوداً ۱۰⁸ تا ۱۰⁹ عملیاتِ ساده در ثانیه انجام میشود. پس O(n²) با n=۱۰⁵ برابرِ حدود ۱۰¹⁰ عملیات → ثانیه تا دقیقه: بیشازحد کند. این عدد به تو اجازه میدهد پیچیدگیِ لازم را از رویِ قیودِ مسئله عقبگرد استنتاج کنی: اگر n ≤ ۲۰ → حتی نمایی هم خوب است؛ اگر n ≤ ۱۰³ → O(n²) قابلقبول؛ اگر n ≤ ۱۰⁶ → به O(n log n) یا بهتر نیاز داری. این ترفند در مسابقاتِ برنامهنویسی طلاست.
دامهای رایج و هزینههای پنهان
اینجا محلی است که مهندسانِ باتجربه هم میلغزند. تقریباً همهی این دامها یک شکلِ واحد دارند: یک عملیاتِ گرانِ پنهان درونِ یک حلقه.
۱. الحاقِ رشته در حلقه → O(n²)
رشتهها در جاوا تغییرناپذیر (immutable) هستند — یعنی نمیشود تغییرشان داد، فقط میشود یک رشتهی جدید ساخت. پس s += x در هر تکرار یک رشتهی کاملاً جدید میسازد و همهی کاراکترهای قبلی را کپی میکند. n بار الحاق روی رشتههای در حالِ رشد برابرِ 1+2+...+n = O(n²) کپیِ کاراکتر است.
// O(n^2): هر += کل رشتهی انباشته را کپی میکند.
String s = "";
for (int i = 0; i < n; i++) s += i; // درجه دو — دام کلاسیک مصاحبه
// O(n): StringBuilder یک char[] قابلتغییر resizeشونده را ویرایش میکند (append سرشکن O(1)).
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) sb.append(i);
String result = sb.toString();
کامپایلر یک عبارتِ منفرد مثلِ a + b + c را خودش به یک StringBuilder بازنویسی میکند، پس الحاقهای مجزا مشکلی ندارند. O(n²) فقط وقتی گاز میگیرد که += درونِ حلقه باشد — چون هر دورِ حلقه یک StringBuilderِ جدید میسازد و دور میریزد.
۲. list.contains() درونِ حلقه → O(n²)
// O(n*m): contains() روی List برابر O(n)، m بار صدا زده میشود.
List<Integer> seen = new ArrayList<>();
for (int x : data) {
if (!seen.contains(x)) seen.add(x); // پویش O(n) در هر تکرار
}
// O(n): HashSet.contains بهطور میانگین O(1).
Set<Integer> seen = new HashSet<>();
for (int x : data) seen.add(x); // حذف تکراری در زمان خطی
میبینی؟ همان contains که روی ArrayList برابرِ O(n) بود، روی HashSet میشود O(1) میانگین، و کلِ کار از O(n²) به O(n) سقوط میکند. این تنها با عوضکردنِ نوعِ کالکشن.
۳. سربارِ autoboxing و تخصیصِ پنهان
Autoboxing یعنی تبدیلِ خودکارِ یک int (نوعِ پایه) به یک Integer (شیء). HashMap<Integer,Integer>، List<Long>، و boxing در stream — هر تبدیلِ int↔Integer یک تخصیصِ حافظه میکند (خارج از کشِ -128..127 که جاوا اعدادِ کوچک را در آن نگه میدارد) و غیرمستقیمسازیِ اشارهگر و فشارِ GC میافزاید. این Big-O را تغییر نمیدهد، اما میتواند ضریبِ ثابت را ۵ تا ۱۰ برابر کند و در حلقههای تنگ کلِ زمانِ اجرا را ببلعد.
// boxing پنهان: sum از نوع Long است، هر += آن را unbox میکند، جمع، دوباره box -> تخصیصها.
Long sum = 0L;
for (long v : values) sum += v; // میلیونها تخصیص Long
// اصلاحشده: انباشتگر primitive، صفر boxing.
long total = 0L;
for (long v : values) total += v;
فقط تفاوتِ Long (بزرگ، شیء) با long (کوچک، پایه) کلِ ماجراست. موردِ ظریفتر: boxing در مسیرِ داغِ کلیدِ map عملکردِ HashMap را کاهش میدهد؛ و map.get(intVar) در هر فراخوانی autobox میکند. برای کارِ سنگین با primitive، کالکشنهای تخصصیِ primitive (مثلِ Eclipse Collections یا fastutil) را در نظر بگیر که هم حافظه و هم ضریبِ ثابت را کم میکنند.
۴. الحاقِ کالکشنها / بازسازیِ immutableها در حلقه
// O(n^2): هر concat لیست در حال رشد را کپی میکند.
List<Integer> acc = List.of();
for (int x : data) acc = concat(acc, List.of(x)); // هر بار کل تاکنون را کپی میکند
این دقیقاً همان شکلِ الحاقِ رشته است. هر «کلِ چیز را کپی کن تا یکی اضافه شود» درونِ حلقه، درجهدو است.
۵. O(n) پنهان در List.remove و shift
ArrayList.remove(i) همهی عناصرِ بعدی را یک خانه به عقب shift میکند: O(n). حذف در یک حلقهی رو به جلو حینِ پیمایش برابرِ O(n²) و اغلب باگدار است. حذفِ مکرر از ابتدای لیست هم O(n²) است — برای این کار از ArrayDeque استفاده کن.
۶. مرتبسازی درونِ حلقه، یا مرتبسازیِ مجدد
یک sort با O(n log n) که درونِ یک حلقهی O(n) قرار گیرد، در کل O(n² log n) میشود. اگر داده تغییر نمیکند، یک بار بیرون از حلقه sort کن.
۷. substring و regex
String.substring برابرِ O(k) است (که k طولِ زیررشته است) — از Java 7u6 به بعد کپی میکند و دیگر آرایهی پشتی را به اشتراک نمیگذارد. پس یک حلقه که substring میگیرد میتواند O(n²) را پنهان کند. و عقبگردِ regex (backtracking) میتواند روی الگوهای بیمارگونه نمایی شود — پدیدهای بهنامِ catastrophic backtracking که میتواند یک سرور را با یک ورودیِ کوچک قفل کند.
۸. .collect در Stream و ساختارهای میانی
Stream رفتارِ مجانبی را تغییر نمیدهد، اما .sorted()، .distinct() (که به یک HashSet نیاز دارد) و .collect(toList())ِ زنجیرهای هرکدام تخصیصِ حافظه میکنند. یک .sorted() در stream برابرِ O(n log n) است حتی اگر فقط عنصرِ اول را بگیری — در این حالت بهجایش از رویکردِ جزئی یا min() استفاده کن.
پیچیدگیِ فضا (space complexity)
همان نماد، اما این بار روی حافظهی اضافی فراتر از ورودی اعمال میشود.
فضای بازگشتی مثلِ رویهمچیدنِ بشقابهاست. هر فراخوانیِ بازگشتی یک بشقاب روی برج میگذارد و تا وقتی برنگردد آن را برنمیدارد. پس فضای پشته (stack) بهاندازهی عمقِ بازگشت هزینه دارد، نه تعدادِ کلِ فراخوانیها. DFS بازگشتی روی یک درخت برابرِ O(h) فضاست (h = ارتفاعِ درخت): برای یک درختِ تباهشده (زنجیرهای) O(n)، و برای یک درختِ متعادل O(log n).
جدولهای حافظهسازی/DP (dynamic programming) برابرِ O(states) هستند (تعدادِ حالتهایی که ذخیره میکنی). یک الگوریتمِ درجا (in-place) برابرِ O(1) فضای کمکی است. مصاحبهکنندهها بیشتر و بیشتر میپرسند «میتوانی در O(1) فضا انجامش دهی؟» — که معمولاً نشانهای است برای استفاده از دو اشارهگر (two pointers)، جابهجاییِ درجا، یا استفاده از خودِ آرایهی ورودی بهعنوانِ دفترچهی موقت.
مراقبِ تبادلِ زمان-فضا (time–space tradeoff) باش: یک HashSet برای حذفِ تکراریِ O(1) برابرِ O(n) حافظه هزینه دارد؛ ترفندِ دو اشارهگر روی دادهی مرتب O(1) فضاست اما ابتدا به O(n log n) زمان برای sort نیاز دارد. هیچ ناهارِ مجانی وجود ندارد.
بهترین شیوهها (خلاصهی عملی)
- تنگترین کران را بیان کن و همیشه کدام حالت و کدام منبع را نام ببر (
Θ(n) زمان، O(1) فضا، بدترین حالت). - وقتی ورودیها مستقلاند از چند متغیر استفاده کن — فروپاشیدنِ
O(V+E)بهO(n)برای گرافها غلط است. - کالکشنها را از پیش اندازهبندی کن (
new HashMap<>(expected/0.75 + 1)،new ArrayList<>(n)) تا resizeهای سرشکن را از بین ببری. ArrayDequeرا بهStack/LinkedListترجیح بده؛ بهطور پیشفرضArrayListرا بهLinkedListترجیح بده.- وقتی حلقهای میبینی، فوراً بپرس «هزینهی بدنه چقدر است؟» —
contains/substring/sortِ پنهان جایی است که درجهدوها مخفی میشوند. - قبل از اصلاحِ کلاسِ مجانبی، ثابتها را ریزبهینه نکن؛ اما وقتی کلاس درست شد، ثابتها را هم نادیده نگیر — محلیتِ کش و boxing واقعیاند.
سوالات مصاحبه
از نظرِ فنی بله — O کرانِ بالاست و n ≤ n² برای n≥1، پس جستوجوی خطی هرگز سریعتر از n² رشد نمیکند. اما این پاسخِ اشتباه در مصاحبه است: آنها کرانِ تنگ Θ(n) را میخواهند. گفتنِ O(n²) نشان میدهد نمیفهمی که کرانِ بالا باید تا حدِ امکان تنگ باشد.
پشتِ آن یک آرایه است؛ وقتی پر شود، add یک آرایهی ۱٫۵ برابر تخصیص میدهد و همه چیز را کپی میکند (O(n)). اما resizeها بهصورتِ هندسی نادرند، پس n بار append در کل O(n) است → O(1) سرشکن. این تمایز برای سیستمهای حساس به تأخیر (latency) مهم است: یک add منفرد میتواند به O(n) جهش کند و باعثِ توقفِ tail-latency شود (مکثی شبیه GC هنگامِ کپیِ آرایهی بزرگ). اگر به تأخیرِ هر عملیاتِ کراندار نیاز داری، لیست را از پیش اندازهبندی کن یا از ساختاری با بدترینحالتِ O(1) مثلِ ساختارِ پیوندی استفاده کن.
با رشدِ جمعی (+k)، برابرِ n/k بار resize میکنی، و resizeِ iاُم برابرِ ~i·k عنصر کپی میکند که در کل به O(n²) جمع میشود → O(n) سرشکن بهازای هر add. فقط رشدِ ضربی هزینههای کپی را به یک سریِ هندسی تبدیل میکند که به O(n) جمع میشود. ضریبِ رشد است که هزینهی سرشکنِ ثابت را میخرد، نه خودِ resize.
نه. میانگین حالت هزینهی موردانتظار روی یک توزیعِ احتمالِ ورودیهاست (get در HashMap بهطور میانگین O(1) است چون فرض میشود کلیدها خوب توزیع شدهاند). سرشکن یک تضمینِ بدترینحالت است که روی یک دنباله از عملیات میانگین گرفته شده، بدونِ هیچ احتمالی — حتی یک دشمن هم نمیتواند n بار add در ArrayList را بیش از O(n) کند. QuickSort بهطور میانگین O(n log n) است (وابسته به توزیعِ ورودی)؛ add در ArrayList برابرِ O(1) سرشکن است (برای هر دنباله برقرار).
پیش از Java 8: بدترینحالت O(n) — همهی کلیدها در یک bucket برخورد میکنند و لیستِ پیوندی میسازند. Java 8+: وقتی یک bucket از ۸ ورودی فراتر رود و جدول ≥۶۴ bucket داشته باشد، به درختِ قرمز-سیاه درختی میشود و بدترینحالت را به O(log n) کران میدهد. میانگین O(1) میماند. این حملهی DoS هشفلادینگ را کاهش میدهد.
اولی: a=2,b=2,c=log₂2=1، f=Θ(n¹) → قضیه حالت ۲ → Θ(n log n) (merge sort). دومی: f=Θ(1)=Θ(n⁰)، اما c=1، پس f=O(n^(1-ε)) → حالت ۱، برگها غالب → Θ(n) (مثلاً شمارشِ گرههای درخت). همان شکلِ بازگشت، هزینهی ترکیبِ متفاوت، پاسخِ متفاوت.
وقتی زیرمسئلهها اندازهی نابرابر دارند (T(n)=T(n/3)+T(2n/3)+n)، وقتی a یا b ثابت نیستند، وقتی f(n) بهصورتِ چندجملهای با n^c قابلِمقایسه نیست (شکاف بین حالتها)، یا وقتی شرطِ regularity در حالت ۳ برقرار نیست. آنگاه از روشِ درختِ بازگشت یا قضیهی Akra–Bazzi استفاده کن.
درجِ O(1) مستلزمِ آن است که از قبل سرِ گره باشی (یک ListIterator را نگه داری)؛ رسیدن به یک اندیسِ دلخواه O(n) است. بدتر، ضریبِ ثابتش وحشتناک است: هر گره یک شیءِ جداگانهی تخصیصیافته است که محلیتِ کش را نابود میکند، پس حتی پیمایشِ O(n) هم بسیار کندتر از ArrayList است. برای stack/queue از ArrayDeque که پیوسته و سریعتر است استفاده کن.
String s = "";
for (int i = 0; i < 5; i++) s += i;
System.out.println(s + " len=" + s.length());
چاپ میکند 01234 len=5. پیچیدگی در کپیِ کاراکترها O(n²) است چون هر += کلِ رشته را بازمیسازد (تغییرناپذیر). اصلاحِ درست: StringBuilder. خروجی درست است؛ دام این است که یک بازبین ممکن است فکر کند O(n) است.
List<Integer> result = new ArrayList<>();
for (int x : input) // n تکرار
if (!result.contains(x)) result.add(x); // هر بار O(n)
contains روی ArrayList برابرِ O(n) است، n بار صدا زده میشود → در کل O(n²). اصلاح: عضویت را در یک HashSet (contains برابرِ O(1)) دنبال کن تا در کل O(n) شود، یا اگر ترتیبِ درج باید حفظ شود از LinkedHashSet استفاده کن.
for (int i = 0; i < n; i++)
for (int j = i; j < n; j++) work();
داخلی n, n-1, ..., 1 بار اجرا میشود → n(n+1)/2 → Θ(n²). حلقهای که از i شروع میشود همچنان درجهدو میدهد؛ فقط شمارندهی ضربیِ نصفشونده برابرِ log n میشود.
بیرونی O(n)، درونی O(log n) (نصفشدن) → در کل O(n log n). شناختنِ i /= 2 بهعنوانِ لگاریتمی کلید است.
بودجه ~۱۰⁸ عملیات/ثانیه. O(n²) = ۴×۱۰¹⁰ → بیشازحد کند. O(n log n) ≈ ۲×۱۰⁵·۱۸ ≈ ۳٫۶×۱۰⁶ → خوب. O(n√n) ≈ ۹×۱۰⁷ → مرزی. پس به O(n log n) یا بهتر نیاز داری — این حلقههای تودرتوی ساده را رد و به سمتِ sort، هرم، یا هشینگ اشاره میکند.
Big-O ثابتها را نادیده میگیرد، اما boxing ثابت را چند برابر میکند: هر Integer/Long خارج از کشِ -128..127 یک تخصیصِ heap با غیرمستقیمسازیِ اشارهگر و هزینهی GC است. حلقهای که در یک انباشتگرِ Long جمع میزند در هر تکرار تخصیص میکند. مجانباً همچنان O(n)، اما ۵ تا ۱۰ برابر کندتر و پرفشار برای GC. اصلاح: انباشتگرهای primitive، کالکشنهای تخصصیِ primitive.
PriorityQueue (هرمِ دودویی). فقط در مسیرهای ریشهبهبرگ مرتب است (ویژگیِ heap)، نه سراسری، پس یافتنِ یک عنصرِ دلخواه نیازمندِ پویش است: contains، remove(Object) برابرِ O(n). فقط peek (O(1)) و poll/offer (O(log n)) از ساختارِ هرم بهره میبرند. ساختنِ decrease-key در Dijkstra بهصورتِ سادهلوحانه روی PriorityQueue.remove یک اشتباهِ کلاسیکِ O(n) بهازای هر عملیات است — بهجای آن تکراریها را push و ورودیهای کهنه را رد کن، یا از هرمِ اندیسدار استفاده کن.
- Big-O یک کرنومتر نیست؛ نرخِ رشد را میسنجد. ضرایبِ ثابت و جملاتِ مرتبهپایین را دور میریزد و میگوید نهایتاً چه کسی برنده است.
- سه نماد:
Oسقف (کرانِ بالا)،Ωکف (کرانِ پایین)،Θهر دو (کرانِ تنگ). همیشه تنگترین را بگو. - حالت (بهترین/میانگین/بدترین) با نماد فرق دارد، و میانگین ≠ سرشکن: میانگین احتمالاتی روی ورودی است، سرشکن تضمینی بدونِ احتمال روی یک دنباله.
ArrayList.addبرابرِ O(1) سرشکن است چون رشدِ ضربیِ ۱٫۵× هزینهی کپی را به یک سریِ هندسی تبدیل میکند؛ رشدِ جمعی این را میشکند.- قضیهی اصلی کارِ برگها (
n^log_b a) را باf(n)مقایسه میکند؛ اگر یادت رفت، درختِ بازگشت را بکش. - جدولِ کالکشنهای JDK را حفظ کن؛
LinkedListرا دور بینداز،HashMapاز Java 8 با درختیشدن bucketهای بزرگ، بدترینحالت را بهO(log n)میرساند. - دامها همه یک شکل دارند: عملیاتِ گرانِ پنهان (الحاقِ رشته،
contains، sort، substring، boxing) درونِ یک حلقه = درجهدو. وقتی حلقه میبینی، بپرس «هزینهی بدنه چقدر است؟»
Let's be honest: most of us memorized "Big-O" as a magic spell — "HashMap is O(1)", "sorting is O(n log n)" — without ever really feeling what sits underneath. This lesson builds that gut feeling. We're going to unpack every term from scratch, so that when you sit down in an interview, you don't just give the right answer — you know why it's right.
Here's the path we'll walk:
- What Big-O actually measures — and why it isn't a stopwatch.
- The three notations O, Θ, Ω — ceiling, floor, and tight bound.
- Best/average/worst case, and its subtle difference from amortized.
- Why
ArrayList.addis O(1) even though it sometimes copies everything. - The Master theorem for recursive algorithms.
- The JDK collections complexity table you must have memorized.
- An interview estimation procedure and the hidden traps that smuggle in quadratics.
- Space complexity and a full interview Q&A section.
Part 0 — words you must know first
Before anything, three foundational words that recur throughout:
- The input
n: the size of whatever you hand the algorithm. The length of a list, the number of users, the character count of a string.nis the hero of every story here. - Asymptotic: an adjective meaning "as
nheads toward infinity." Picture looking at the algorithm's growth curve through a camera from very far away; the close-up details blur out and only the overall shape of the growth remains. Big-O is that far-away view. - Constant factor: a number that doesn't ride along with the growth. If one algorithm does
5noperations and another does100n, both are "linear" (O(n)); the 5 and 100 are constant factors, and Big-O deliberately ignores them.
Picture two courier companies. Company A spends 30 minutes per parcel, but its time doubles as orders pile up. Company B spends 2 hours per parcel, but its time stays flat regardless of order count. For 3 parcels, A wins easily. But for 10,000 parcels, B strolls past it. Big-O tells you exactly this: not who's faster right now, but who wins when the load gets large.
What Big-O actually measures
Everyone's first misconception is that Big-O is a stopwatch that says "this code takes 2 milliseconds." No. Big-O measures how the resource cost of an algorithm grows as the input size n grows toward infinity, ignoring constant factors and lower-order terms.
That "resource" is usually time (number of primitive operations) or space (extra memory), but it can be anything: I/O calls, network round-trips, number of comparisons, or even cache misses (when the data the CPU wants isn't in fast nearby memory and has to be fetched from slow distant memory).
The single most important sentence to burn in:
Big-O describes growth rate, not absolute speed. An O(n) algorithm can be slower than an O(n²) algorithm for the inputs you actually run — right up until n gets large enough. Constants and the crossover point matter in production; asymptotics only tell you who wins eventually.
Let's see it as a graph. The vertical axis is cost, the horizontal is n. Each curve is a complexity class:
Cost
^ O(2^n) O(n^2)
| / /
| / /
| / / O(n log n)
| / / /
| / / / O(n)
| / / / ______/
| / / /____/______________ O(log n)
| //_/_______________________ O(1)
+----------------------------------------------------> n
See how O(2^n) and O(n²) rocket upward, while O(log n) and O(1) stay almost flat. Memorize this picture; the intuition for the whole chapter lives in it.
And here is the ordering of the common classes, best (slowest growth) to worst (fastest growth):
O(1) < O(log n) < O(√n) < O(n) < O(n log n) < O(n²) < O(n³) < O(2ⁿ) < O(n!)
O(1) means "no matter how big the input, cost is constant" — like reading the first page of a book. O(log n) means "the input grows a thousandfold, the cost only grows a few times" — like finding a word in a dictionary by halving. O(n) means "cost grows in lockstep with input" — reading the whole book. And O(n²) means "you compare every element against every other" — like everyone at a party shaking hands with everyone else.
The three notations: O, Θ, Ω
Everyone colloquially says "Big-O," but there are really three distinct bounds. Knowing the difference is a senior-level signal.
O is like a "Maximum 120" sign: it tells you that you never go faster than this (upper bound). Ω is like a "Minimum 60" sign: you never go slower than this (lower bound). And Θ is when both signs show the same number: you know you're driving at exactly that speed (tight bound). A car can honor "Maximum 120" while crawling at 20 — the upper bound is correct but loose and useless.
| Notation | Name | Meaning | Bound |
|---|---|---|---|
O(f) |
Big-O | grows no faster than f |
upper bound |
Ω(f) |
Big-Omega | grows no slower than f |
lower bound |
Θ(f) |
Big-Theta | grows exactly like f |
tight bound (both) |
Now the precise, mathematical version, which looks scary but is simple. Formally, g(n) = O(f(n)) iff there exist constants c > 0 and n₀ such that g(n) ≤ c·f(n) for all n ≥ n₀. In plain words: "from some point on (n₀), if I multiply f by a fixed number, it always stays above g." For Θ, g must be sandwiched between two bounds: c₁·f(n) ≤ g(n) ≤ c₂·f(n).
O is an upper bound, so it's technically correct to say linear search is O(n²) — it never grows faster than n². That's true but useless, exactly like saying "this car goes at most 1000 km/h." You want the tightest correct bound, which is Θ(n). When an interviewer asks "what's the complexity," they want the tight bound. When they ask "worst case," the answer is a Big-O on the worst-case input.
One last subtlety many people conflate: O, Θ, Ω are about growth, and are completely independent (orthogonal) of the best/average/worst case concept. That means you can state the worst-case complexity both tightly (Θ) and loosely (O). Saying "QuickSort is O(n log n)" is sloppy because its worst case is Θ(n²); but "QuickSort is O(n²) worst case, Θ(n log n) average case" is precise and professional.
Best, average, and worst case
"Case" means which input you're measuring. Three matter:
Think of the drive from home to work. A worst case day is when a crash, rain, and traffic all pile up — the latest you could possibly arrive. Average case is the time it usually takes over a month. Best case is that dream morning where every light is green. To make a promise to your boss ("I'll be in by 9 every day"), you plan around the worst case, not the best.
- Worst case: the input that maximizes cost. This is what you design your SLA around, because it bounds tail latency (the latency of the worst percentile of requests). HashMap
getis worst-caseO(n)if every key collides into one bucket. - Average case: expected cost over a distribution of inputs. HashMap
getis averageO(1). QuickSort is averageΘ(n log n). - Best case: the luckiest input. Insertion sort is
O(n)on already-sorted data. Rarely useful for planning, occasionally useful as an optimization (early-exit).
This is where many people slip. Average case is probabilistic over inputs — it assumes inputs arrive with some distribution. Amortized is a guarantee averaged over a sequence of operations, with no probability involved. Even an adversary picking the worst possible inputs cannot break an amortized guarantee. Don't conflate them; we unpack amortized fully next.
Amortized analysis: why ArrayList.add is O(1)
Here's a puzzle. ArrayList is backed by a plain array. When the array fills up, add is forced to allocate a bigger array and copy every element — which is obviously an O(n) operation. So how dare we claim add is O(1)?
Imagine you shower every morning. 99 days out of 100, you just turn the tap and hot water comes — cheap and instant. But one day the heater breaks and you have to replace the whole thing — expensive. If you spread the cost of that one expensive day across all 100 days, the "amortized" cost per shower is still tiny. That's exactly what amortized means: you spread the cost of the rare expensive operation across all the cheap ones.
Why does this spreading work out? Because the expensive resizes are rare and get rarer. ArrayList grows by a factor of about 1.5 — in the JDK source it's exactly newCap = oldCap + (oldCap >> 1) (old capacity plus half of it). This multiplicative growth means that to reach size n you resize only about log(n) times, and the total copying work across all resizes is a geometric series that sums to O(n), not O(n²).
Let's see it numerically. Suppose the array doubles (capacities 1, 2, 4, 8, ...):
Appends into a doubling array (capacities 1,2,4,8...):
copies per resize: 1 1 2 4 8 ... n/2
sum of copies = 1 + 2 + 4 + ... + n/2 < n (geometric)
=> total work for n inserts = O(n) => O(1) amortized each
The magic of the geometric series is that 1 + 2 + 4 + ... + n/2 always ends up less than n, not more. So the total cost of n appends is O(n). Divide by n operations → O(1) amortized per add. Any single add might be O(n), but no sequence of n adds can cost more than O(n) total.
The formal tool behind this argument is the accounting method (or potential method): you overcharge the cheap operations to bank "credit" that later pays for the rare expensive one. Each cheap add pays 1 unit for itself plus about 2 units of credit; when a resize happens, the banked credit covers the copies. Just like the water heater: you set a little money aside every month so the fund is ready on breakdown day.
This is the critical point. If ArrayList grew by a constant amount (say +10 each time), you'd resize n/10 times, each copy averaging n/2 elements, so total work would be O(n²) and add would collapse to O(n) amortized. Only multiplicative growth (×1.5 or ×2) turns the copy costs into a small geometric series. That's why, when you know the size ahead of time, you should call new ArrayList<>(expectedSize) — you skip all the resizes at once.
This same amortized logic underlies HashMap (amortized O(1) put, which rehashes on resize), and likewise StringBuilder and ArrayDeque.
// Anti-pattern: forces repeated resizes if the list grows huge.
List<Integer> a = new ArrayList<>(); // starts empty, grows on demand
for (int i = 0; i < 1_000_000; i++) a.add(i); // ~O(n) amortized total, ~20 resizes
// Better when size is known: zero resizes, one allocation.
List<Integer> b = new ArrayList<>(1_000_000);
for (int i = 0; i < 1_000_000; i++) b.add(i);
Recurrences and the Master theorem
Recursive and divide-and-conquer algorithms produce a special kind of equation called a recurrence — expressing the cost of size n in terms of the cost of smaller subproblems:
T(n) = a·T(n/b) + f(n)
where a = the number of subproblems, b = the factor the problem shrinks by, and f(n) = the work to split and combine.
A manager takes a big project (n). They break it into a pieces and hand each to a sub-manager, where each piece is b times smaller (n/b). The manager themselves does a bit of work: splitting the task and later merging the results — that's f(n). The question is: where does the real load sit? On the shoulders of the top-row managers (the combine step), or on the thousands of bottom-row workers (the leaves)? The Master theorem decides exactly this.
The Master theorem compares the work of the leaves (n^(log_b a)) against f(n). Let c = log_b(a) (this is the "leaf work").
| Case | Condition | Result | Intuition |
|---|---|---|---|
| 1 | f(n) = O(n^(c-ε)) |
Θ(n^c) |
leaves dominate |
| 2 | f(n) = Θ(n^c · logᵏn) |
Θ(n^c · logᵏ⁺¹n) |
balanced, every level costs the same |
| 3 | f(n) = Ω(n^(c+ε)) (+ regularity) |
Θ(f(n)) |
root/combine dominates |
(That ε is a small positive number; it just means "polynomially smaller/larger.") Now four worked examples you must recognize instantly:
- Merge sort:
T(n) = 2T(n/2) + O(n). Herea=2, b=2, c=log₂2=1, andf(n)=n=Θ(n¹)→ Case 2 →Θ(n log n). Every level doesO(n)merge work; there arelog nlevels. - Binary search:
T(n) = T(n/2) + O(1).a=1, b=2, c=0,f=Θ(1)=Θ(n⁰)→ Case 2 →Θ(log n). - Recursive traversal splitting in two, constant combine:
T(n)=2T(n/2)+O(1).c=1,f=O(n^(1-ε))→ Case 1 →Θ(n)(e.g. counting a tree's size). - Karatsuba multiplication:
T(n)=3T(n/2)+O(n).c=log₂3≈1.585,f=O(n^(c-ε))→ Case 1 →Θ(n^1.585).
The quick heuristic without the theorem: draw the recursion tree, compute the work per level, sum the levels. If the work per level is equal and there are log n levels → n log n. If work doubles going down → leaves dominate. If work halves going down → root dominates. This mental picture never leaves you, unlike three rows of a table.
The Master theorem does not apply when subproblems have unequal sizes (e.g. T(n)=T(n/3)+T(2n/3)+n), or when a/b aren't constants. There, use the recursion-tree method or the Akra–Bazzi theorem instead.
Complexity of common JDK operations
Each collection is like a tool in the box. HashMap is the hammer — fast and versatile for "where is this key?". TreeMap is a tape measure — slower, but gives you order and ranges. ArrayDeque is a double-ended plier — fast from both ends. A good engineer matches the tool to the job, rather than hammering everything.
Memorize this table — interviewers probe it directly and it drives real design decisions.
| Structure | Access/Get | Search/Contains | Insert | Delete | Notes |
|---|---|---|---|---|---|
ArrayList |
O(1) index |
O(n) |
O(1)* end, O(n) middle |
O(n) |
*amortized at end; shifts on middle ops |
LinkedList |
O(n) |
O(n) |
O(1) at known node |
O(1) at known node |
traversal to reach node is O(n) |
HashMap |
O(1) avg / O(log n) worst |
same | O(1) avg |
O(1) avg |
worst was O(n); treeified buckets → O(log n) since Java 8 |
LinkedHashMap |
O(1) avg |
O(1) avg |
O(1) avg |
O(1) avg |
maintains insertion/access order |
TreeMap |
O(log n) |
O(log n) |
O(log n) |
O(log n) |
red-black tree, sorted, gives floor/ceiling/subMap |
PriorityQueue |
O(1) peek |
O(n) contains |
O(log n) offer |
O(log n) poll |
binary heap; remove(Object) is O(n) |
ArrayDeque |
O(1) ends |
O(n) |
O(1)* ends |
O(1)* ends |
faster than LinkedList as stack/queue |
HashSet |
— | O(1) avg |
O(1) avg |
O(1) avg |
backed by HashMap |
TreeSet |
— | O(log n) |
O(log n) |
O(log n) |
sorted, navigable |
Now the key nuances a senior must know, one by one:
Everyone thinks "LinkedList has O(1) insert, so it's better" — that's a trap. That O(1) insert only helps if you already hold the node (via a ListIterator). But get(i) is O(n) because you have to count from the head. Worse: every node is a separately-allocated object, so cache locality (having related data sit next to each other in memory so the CPU can read it fast) is destroyed. The result is that even O(n) traversal is far slower on LinkedList than on ArrayList. For stack/queue use ArrayDeque.
A beautiful improvement landed in Java 8. When a single bucket exceeds 8 entries and the table has at least 64 buckets, that bucket converts from a linked list into a red-black tree ("treeify"). This bounds worst-case lookup from O(n) down to O(log n). Why does it matter? It defends against a DoS attack: an attacker could deliberately craft keys that all collide into one bucket and cripple the server with an O(n) linear bucket. When the bucket shrinks back below 6 entries, it reverts to a list.
PriorityQueue.remove(Object)andcontainsareO(n)— because a heap is only ordered along root-to-leaf paths, not globally. So finding an arbitrary element means a linear scan of the whole array. Don't build a naive "decrease-key" on top of it.TreeMapbuys you order at the cost of speed —firstKey,floorKey,ceilingKey,headMap,subMap, and range scans, allO(log n). If you need range queries or sorted iteration, thisO(log n)vsO(1)tradeoff is worth it.HashMapgives you nothing ordered.- Stream/iteration cost: iterating any of these is
O(n), butHashMapiteration order is unspecified and its cost is proportional to capacity, not size — a huge, mostly-empty map iterates slowly.
Estimating complexity in an interview
Here's a repeatable procedure you can follow like a checklist:
- Identify the size variables — there's often more than one (
n= list length,m= query count,k= alphabet/branching size). Answer in all of them:O(n·m), not a single letter. - Nested loops multiply; sequential loops add. Two independent back-to-back passes =
O(n) + O(n) = O(n). A loop inside a loop over the samen=O(n²). - Watch for loops that don't run
ntimes.for (i=1; i<n; i*=2)runs onlylog ntimes, becauseidoubles each pass. Any loop whose counter halves or doubles is logarithmic. - Recursion → recurrence → Master theorem (or count the tree).
- Account for hidden work inside the loop body — a
.contains()on a list, asubstring, a sort, a rehash. Most wrong answers come exactly from here (see traps below). - Drop constants and non-dominant terms at the end:
O(3n² + 5n + 100)→O(n²).
On typical hardware, roughly 10⁸–10⁹ simple ops per second. So O(n²) with n=10⁵ is about 10¹⁰ ops → seconds to minutes: too slow. This number lets you back-infer the required complexity from the problem's constraints: if n ≤ 20 → even exponential is fine; if n ≤ 10³ → O(n²) is OK; if n ≤ 10⁶ → you need O(n log n) or better. This trick is gold in competitive programming.
Common traps and hidden costs
This is where even experienced engineers slip. Almost all these traps have one shape: a hidden expensive operation inside a loop.
1. String concatenation in a loop → O(n²)
Strings in Java are immutable — meaning they can't be changed, only rebuilt into a new string. So s += x builds a brand-new string each iteration, copying all prior characters. n concatenations of growing strings is 1+2+...+n = O(n²) character copies.
// O(n^2): each += copies the whole accumulated string.
String s = "";
for (int i = 0; i < n; i++) s += i; // quadratic — classic interview trap
// O(n): StringBuilder mutates a resizable char[] (amortized O(1) append).
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) sb.append(i);
String result = sb.toString();
The compiler rewrites a single expression like a + b + c into one StringBuilder itself, so isolated concatenations are fine. The O(n²) only bites when += is inside a loop — because each pass builds and throws away a fresh StringBuilder.
2. list.contains() inside a loop → O(n²)
// O(n*m): contains() is O(n) on a List, called m times.
List<Integer> seen = new ArrayList<>();
for (int x : data) {
if (!seen.contains(x)) seen.add(x); // O(n) scan each iteration
}
// O(n): HashSet.contains is O(1) average.
Set<Integer> seen = new HashSet<>();
for (int x : data) seen.add(x); // dedup in linear time
See it? The same contains that was O(n) on an ArrayList becomes O(1) average on a HashSet, and the whole thing drops from O(n²) to O(n). Just by swapping the collection type.
3. Autoboxing overhead and hidden allocation
Autoboxing means the automatic conversion of an int (a primitive) into an Integer (an object). HashMap<Integer,Integer>, List<Long>, boxing in streams — every int↔Integer conversion allocates memory (outside the -128..127 cache where Java keeps small numbers) and adds pointer indirection and GC pressure. It doesn't change Big-O, but it can 5–10× the constant factor and, in tight loops, dominate runtime.
// Hidden boxing: sum is Long, each += unboxes, adds, re-boxes -> allocations.
Long sum = 0L;
for (long v : values) sum += v; // millions of Long allocations
// Fixed: primitive accumulator, zero boxing.
long total = 0L;
for (long v : values) total += v;
The whole story is just the difference between Long (big, an object) and long (small, a primitive). A subtler one: boxing in a hot map-key path degrades HashMap; and map.get(intVar) autoboxes on every call. For primitive-heavy work, consider primitive-specialized collections (like Eclipse Collections or fastutil) which cut both memory and the constant factor.
4. Concatenating collections / rebuilding immutables in a loop
// O(n^2): each concat copies the growing list.
List<Integer> acc = List.of();
for (int x : data) acc = concat(acc, List.of(x)); // copies all-so-far each time
This is the exact same shape as string concat. Any "copy the whole thing to add one" inside a loop is quadratic.
5. Hidden O(n) in List.remove and shifting
ArrayList.remove(i) shifts all following elements one slot back: O(n). Removing in a forward loop while iterating is O(n²) and often buggy. Removing from the front repeatedly is also O(n²) — use ArrayDeque for that.
6. Sorting inside a loop, or re-sorting
An O(n log n) sort placed inside an O(n) loop is O(n² log n) overall. If the data doesn't change, sort once outside the loop.
7. Substring and regex
String.substring is O(k) (where k is the substring length) — since Java 7u6 it copies and no longer shares the backing array. So a loop taking substrings can hide an O(n²). And regex backtracking can be exponential on pathological patterns — a phenomenon called catastrophic backtracking that can lock up a server with a small input.
8. Stream .collect and intermediate structures
Streams don't change asymptotics, but chained .sorted(), .distinct() (which needs a HashSet), and .collect(toList()) each allocate. A .sorted() in a stream is O(n log n) even if you only take the first element — in that case use a partial approach or min() instead.
Space complexity
Same notation, but this time applied to the extra memory beyond the input.
Recursion space is like stacking plates. Each recursive call puts one plate on the tower and doesn't remove it until it returns. So stack space costs the depth of recursion, not the total number of calls. Recursive DFS on a tree is O(h) space (h = tree height): O(n) for a degenerate (chain-like) tree, O(log n) for a balanced one.
Memoization/DP tables cost O(states) (the number of states you store). An in-place algorithm is O(1) auxiliary space. Interviewers increasingly ask "can you do it in O(1) space?" — usually a signal to use two pointers, in-place swaps, or the input array itself as scratch space.
Beware the time–space tradeoff: a HashSet for O(1) dedup costs O(n) memory; the two-pointer trick on sorted data is O(1) space but first needs O(n log n) time to sort. There's no free lunch.
Best practices (practical recap)
- State the tightest bound and always name which case and which resource (
Θ(n) time, O(1) space, worst case). - Use multiple variables when inputs are independent — collapsing
O(V+E)toO(n)is wrong for graphs. - Pre-size collections (
new HashMap<>(expected/0.75 + 1),new ArrayList<>(n)) to kill amortized resizes. - Prefer
ArrayDequeoverStack/LinkedList; preferArrayListoverLinkedListby default. - When you see a loop, immediately ask "what's the cost of the body?" — the hidden
contains/substring/sort is where quadratics hide. - Don't micro-optimize constants before you've fixed the asymptotic class; but once the class is right, don't ignore constants either — cache locality and boxing are real.
Interview Questions
Technically yes — O is an upper bound and n ≤ n² for n≥1, so linear search never grows faster than n². But it's the wrong answer in an interview: they want the tight bound Θ(n). Saying O(n²) signals you don't understand that upper bounds should be as tight as possible.
Backed by an array; when full, add allocates a 1.5× array and copies everything (O(n)). But resizes are geometrically rare, so n appends total O(n) → O(1) amortized. The distinction matters for latency-sensitive systems: a single add can spike to O(n), causing a tail-latency stall (a GC-like pause when copying a huge array). If you need bounded per-op latency, pre-size the list or use a structure with worst-case O(1) like a linked structure or a chunked/rope list.
With additive growth (+k), you resize n/k times, and the i-th resize copies ~i·k elements, summing to O(n²) total → O(n) amortized per add. Only multiplicative growth makes the copy costs a geometric series that sums to O(n). The growth factor is what buys the constant amortized cost, not the resizing itself.
No. Average case is expected cost over a probability distribution of inputs (HashMap get is O(1) average because keys are assumed well-distributed). Amortized is a worst-case guarantee averaged over a sequence of operations, with no probability involved — even an adversary can't make n ArrayList adds cost more than O(n). QuickSort is O(n log n) average (depends on input distribution); ArrayList add is O(1) amortized (holds for any sequence).
Pre-Java 8: O(n) worst case — all keys collide into one bucket forming a linked list. Java 8+: when a bucket exceeds 8 entries and the table has ≥64 buckets, it treeifies into a red-black tree, bounding worst-case at O(log n). Average remains O(1). This mitigates hash-flooding DoS attacks.
First: a=2,b=2,c=log₂2=1, f=Θ(n¹) → Master Case 2 → Θ(n log n) (merge sort). Second: f=Θ(1)=Θ(n⁰), but c=1, so f=O(n^(1-ε)) → Case 1, leaves dominate → Θ(n) (e.g. counting tree nodes). Same recursion shape, different combine cost, different answer.
When subproblems are unequal sizes (T(n)=T(n/3)+T(2n/3)+n), when a or b aren't constants, when f(n) isn't polynomially comparable to n^c (the "gap" between cases), or when the regularity condition fails in Case 3. Use the recursion-tree method or the Akra–Bazzi theorem instead.
The O(1) insert requires you to already be at the node (holding a ListIterator); reaching an arbitrary index is O(n). Worse, its constant factor is terrible: every node is a separately-allocated object, destroying cache locality, so even O(n) traversal is far slower than ArrayList's. For stack/queue use ArrayDeque, which is contiguous and faster.
String s = "";
for (int i = 0; i < 5; i++) s += i;
System.out.println(s + " len=" + s.length());
Prints 01234 len=5. Complexity is O(n²) in character copies because each += rebuilds the whole string (immutable). Correct fix: StringBuilder. The output is right; the trap is that a reviewer might think it's O(n).
List<Integer> result = new ArrayList<>();
for (int x : input) // n iterations
if (!result.contains(x)) result.add(x); // O(n) each
contains on an ArrayList is O(n), called n times → O(n²) overall. Fix: track membership in a HashSet (O(1) contains) for O(n) total, or use a LinkedHashSet if insertion order must be preserved.
for (int i = 0; i < n; i++)
for (int j = i; j < n; j++) work();
Inner runs n, n-1, ..., 1 times → n(n+1)/2 → Θ(n²). A loop starting at i still gives quadratic; only a multiplicatively shrinking counter gives log n.
Outer is O(n), inner is O(log n) (halving) → O(n log n) total. Recognizing the i /= 2 as logarithmic is the key.
~10⁸ ops/sec budget. O(n²) = 4×10¹⁰ → too slow. O(n log n) ≈ 2×10⁵·18 ≈ 3.6×10⁶ → fine. O(n√n) ≈ 9×10⁷ → borderline OK. So you need O(n log n) or better — this rules out naive nested loops and points toward sorting, heaps, or hashing.
Big-O ignores constants, but boxing multiplies the constant: each Integer/Long outside the -128..127 cache is a heap allocation with pointer indirection and GC cost. A loop summing into a Long accumulator allocates on every iteration. Asymptotically still O(n), but 5–10× slower and GC-heavy. Fix: primitive accumulators, primitive-specialized collections.
PriorityQueue (binary heap). It's ordered only along root-to-leaf paths (heap property), not globally, so locating an arbitrary element requires scanning: contains, remove(Object) are O(n). Only peek (O(1)) and poll/offer (O(log n)) exploit the heap structure. Building a Dijkstra decrease-key naively on PriorityQueue.remove is a classic O(n)-per-op mistake — instead push duplicates and skip stale entries, or use an indexed heap.
- Big-O is not a stopwatch; it measures growth rate. It throws away constant factors and lower-order terms and tells you who wins eventually.
- Three notations:
Ois the ceiling (upper bound),Ωthe floor (lower bound),Θboth (tight bound). Always give the tightest. - Case (best/avg/worst) is orthogonal to notation, and average ≠ amortized: average is probabilistic over inputs; amortized is a probability-free guarantee over a sequence.
ArrayList.addis O(1) amortized because 1.5× multiplicative growth turns the copy cost into a geometric series; additive growth breaks it.- The Master theorem compares leaf work (
n^log_b a) againstf(n); if you forget it, draw the recursion tree. - Memorize the JDK collections table; avoid
LinkedList, and know that Java 8's bucket treeification boundsHashMapworst-case toO(log n). - The traps all share one shape: a hidden expensive operation (string concat,
contains, sort, substring, boxing) inside a loop = quadratic. When you see a loop, ask "what's the cost of the body?"