Data Structures & Algorithms · ساختمان‌داده و الگوریتم متوسطIntermediate ~54 دقیقه مطالعه~44 min read

الگوهای الگوریتمی + مسائل کلیدیAlgorithm Patterns + Must-Know Problems

یاد می‌گیری که مصاحبهٔ کدنویسی «اختراع» نیست، «تشخیص الگو» است — و با تشبیه، شهود و کد جاوای واقعی، هر ۱۲ الگوی پرکاربرد (دو اشاره‌گر، پنجرهٔ لغزان، تند/کند، جمع پیشوندی، جست‌وجوی دودویی روی پاسخ، backtracking، حریصانه، DP، بازه‌ها، پشتهٔ یکنوا و هیپ) را از صفر می‌سازی تا در ۹۰ ثانیهٔ اول مسئله را جا بیندازی.You'll learn that a coding interview is pattern recognition, not invention — and through analogies, intuition, and real Java code you'll build all ~12 high-yield patterns (two pointers, sliding window, fast/slow, prefix sums, binary search on the answer, backtracking, greedy, DP, intervals, monotonic stack, and heaps) from scratch, so you can map any problem in the first 90 seconds.

پیش‌نیاز:Prerequisites: نماد Big-O و تحلیل پیچیدگیBig-O & Complexity Analysis


فرض کن یک آشپز باتجربه‌ای که پنج سال است در آشپزخانه کار می‌کنی. کسی از تو نمی‌خواهد زیر فشار، دستور یک غذای تازه را «اختراع» کنی. از تو انتظار دارند تا با یک نگاه به مواد اولیهٔ روی میز، بفهمی این می‌شود سوپ، آن یکی می‌شود خوراک، و سراغ همان تکنیکی بروی که هزار بار تمرینش کرده‌ای. مصاحبهٔ الگوریتمی دقیقاً همین است. هدف این فصل این است که تو را از «کسی که هر مسئله را از صفر حل می‌کند» به «کسی که در چند ثانیه شکل مسئله را می‌شناسد» تبدیل کند.

نقشهٔ راه این فصل

در این فصل با هم این مسیر را می‌رویم: اول یاد می‌گیریم چطور از روی شکل ورودی و صورت خواسته الگو را تشخیص بدهیم. بعد ۱۱ الگوی اصلی را یکی‌یکی می‌شکافیم — دو اشاره‌گر، پنجرهٔ لغزان، اشاره‌گر تند/کند، جمع پیشوندی، جست‌وجوی دودویی (حتی روی فضای پاسخ)، backtracking، حریصانه، برنامه‌ریزی پویا، بازه‌ها، پشتهٔ یکنوا و هیپ‌ها. برای هر الگو یک تشبیه، یک تا دو مسئلهٔ حل‌شده با کد جاوای قابل‌کامپایل، و پیچیدگی‌ای که باید بی‌درنگ بگویی می‌آوریم. آخر فصل هم یک بخش کامل «تله‌های رایج»، «آدابِ خودِ مصاحبه» و ۱۵ سؤال مصاحبهٔ واقعی با پاسخ کامل داریم.

بخش صفر — چند واژه که باید بی‌درنگ بشناسی

قبل از شروع، چهار واژه را می‌سازیم تا بعداً هیچ‌جا سرد و بی‌توضیح رهایشان نکنیم.

  • پیچیدگی O(n) (اُ-بزرگ): یعنی «کارِ الگوریتم چطور با بزرگ‌شدن ورودی رشد می‌کند». تصور کن باید در یک دفترچه‌تلفنِ n صفحه‌ای دنبال کسی بگردی. اگر صفحه‌به‌صفحه بروی، O(n) است؛ اگر مثل جست‌وجوی دودویی هر بار نصفش کنی، O(log n). O(n²) یعنی برای هر عنصر یک بار کل آرایه را می‌پیمایی.
  • brute force (زورِ خام): ساده‌ترین راه‌حلی که به ذهن می‌رسد، حتی اگر کند باشد — مثلاً امتحان کردنِ همهٔ زوج‌ها. همیشه اول این را بگو؛ هم مبنای درستی است، هم برایت وقت فکر می‌خرد.
  • ناوردا (invariant): یک گزارهٔ درست که در تمام طول اجرای حلقه، درست باقی می‌ماند. مثل قانونِ خانه که هرگز نقض نمی‌شود. مثلاً در آرایهٔ مرتب، «هر زوجی که رد کردیم قطعاً جواب نبود» یک ناوردا است.
  • یکنوا (monotonic): چیزی که فقط در یک جهت حرکت می‌کند — یا همیشه بزرگ‌تر می‌شود یا همیشه کوچک‌تر، هیچ‌وقت بالا-پایین نمی‌پرد. مثل دمای آبِ روی گاز که مدام بالا می‌رود. کلمهٔ «یکنوا» را زیاد خواهی دید، چون قلبِ خیلی از این الگوهاست.
چرا این چهار واژه؟

تقریباً هر تصمیمِ الگوریتمی در این فصل به یکی از این‌ها برمی‌گردد: «آیا این کمیت یکنوا است؟» تعیین می‌کند پنجرهٔ لغزان جواب می‌دهد یا نه. «ناوردا چیست؟» تعیین می‌کند چرا رد کردن یک زوج امن است. و همیشه با brute force شروع کن تا از صفر گیر نکنی.

مدل ذهنی: مصاحبه یعنی تشخیص الگو، نه اختراع

با ۵ سال سابقه، از تو انتظار نمی‌رود که زیر فشار الگوریتم دایکسترا را از صفر «استخراج» کنی. انتظار این است که در ۹۰ ثانیهٔ اول، مسئله را روی یک الگوی شناخته‌شده نگاشت (map) کنی، راه‌حل brute force را بگویی، و سپس سراغ الگویی بروی که یک ضریب n را حذف می‌کند (یا نمایی را به چندجمله‌ای تبدیل می‌کند).

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

نشانه در مسئله سراغ این الگو برو
آرایهٔ مرتب / زوجی با مجموع برابر هدف دو اشاره‌گر (two pointers)
«زیرآرایه/زیررشتهٔ پیوسته»، طولانی‌ترین/کوتاه‌ترین پنجره با یک قید پنجره لغزان (sliding window)
حلقه در لیست پیوندی، یافتن وسط، «بدون حافظهٔ اضافه» اشاره‌گر تند/کند (fast/slow)
کوئری‌های زیاد روی مجموع بازه، مجموع زیرآرایه جمع پیشوندی (prefix sums)
مرتب، «X را پیدا کن»، یا کمینه‌سازی بیشینه / بیشینه‌سازی کمینه جست‌وجوی دودویی (شامل روی فضای پاسخ)
«تمام ترکیب‌ها/جایگشت‌ها/زیرمجموعه‌ها»، ارضای قید بازگشت‌پذیری (backtracking)
انتخاب محلی بهینه → سراسری بهینه، زمان‌بندی حریصانه (greedy)
«تعداد راه‌ها»، «کمترین/بیشترین هزینه»، زیرمسئله‌های هم‌پوشان برنامه‌ریزی پویا (dynamic programming)
جلسات، ادغام بازه‌ها، هم‌پوشانی بازه‌ها (مرتب‌سازی + جاروب)
«عنصر بزرگ‌تر/کوچک‌تر بعدی»، هیستوگرام پشتهٔ یکنوا (monotonic stack)
«K تای برتر»، «K نزدیک‌ترین»، میانهٔ جریانی هیپ (heap) / quickselect
جدول بالا مثل نقشهٔ خطوط مترو است

یک مسافر تازه‌وارد، نقشهٔ مترو را باز می‌کند، مبدأ و مقصدش را پیدا می‌کند، و بی‌درنگ می‌فهمد کدام خط را سوار شود. تو هم قرار نیست هر بار شهر را از نو کشف کنی؛ فقط دو چیز را روی نقشه پیدا می‌کنی — «شکل ورودی» و «چیزی که خواسته‌اند» — و خط درست، خودش خودش را نشان می‌دهد. این جدول را آن‌قدر نگاه کن تا مثل نقشهٔ مترویِ شهر خودت، حفظ شوی.

بقیهٔ این فصل هر الگو را می‌شکافد: نشانهٔ تشخیص، یک یا دو مسئلهٔ حل‌شده با جاوای اصولی، و پیچیدگی‌ای که باید بدون فکر بتوانی بگویی.


۱. دو اشاره‌گر (two pointers)

دو نفر که از دو سرِ یک صف به هم نزدیک می‌شوند

تصور کن یک صفِ مرتب از آدم‌ها بر اساس قد ایستاده‌اند و تو دنبال دو نفری می‌گردی که مجموع قدشان دقیقاً ۳۴۰ سانتی‌متر شود. لازم نیست همهٔ زوج‌ها را امتحان کنی. یک نفر را دمِ کوتاه‌ترین سر بگذار و یکی را دمِ بلندترین. اگر مجموعشان کم بود، آنکه سمت کوتاه‌هاست را یک قدم جلو ببر (قدها بزرگ‌تر می‌شوند)؛ اگر زیاد بود، آنکه سمت بلندهاست را عقب بکش. چون صف مرتب است، هر بار دقیقاً می‌دانی کدام‌طرف را حرکت بدهی.

تشخیص: یک آرایهٔ مرتب (یا آرایه‌ای که می‌توانی مرتبش کنی) و نیاز به یافتن زوج/سه‌تایی، یا افراز درجا (in place). دو اندیس که به سمت هم حرکت می‌کنند (یا هم‌جهت، یکی عقب‌تر) یک پیمایش O(n²) را به O(n) تبدیل می‌کنند. کلمهٔ کلیدی «مرتب» است — مرتب‌بودن همان چیزی است که به تو اجازه می‌دهد نیمی از زوج‌ها را بی‌خیال شوی.

Two Sum II (ورودی مرتب)

// آرایه صعودی است. اندیس‌های ۱-پایه زوجی با مجموع برابر target را برگردان.
int[] twoSumSorted(int[] a, int target) {
    int lo = 0, hi = a.length - 1;
    while (lo < hi) {
        int sum = a[lo] + a[hi];
        if (sum == target) return new int[]{lo + 1, hi + 1};
        if (sum < target) lo++;   // به بزرگ‌تر نیاز داریم -> اشاره‌گر چپ را بالا ببر
        else hi--;                // به کوچک‌تر نیاز داریم -> اشاره‌گر راست را پایین بیاور
    }
    return new int[]{-1, -1};
}

بیایید خط‌به‌خط بفهمیمش. lo همان نفرِ سمتِ کوتاه‌هاست و hi نفرِ سمتِ بلندها. تا وقتی به هم نرسیده‌اند (lo < hi) مجموعشان را حساب می‌کنیم. اگر کم بود، lo++ یعنی «برو سراغ یکی بلندتر». اگر زیاد بود، hi-- یعنی «برو سراغ یکی کوتاه‌تر». زمان O(n)، حافظه O(1).

ناوردایی که این الگو را امن می‌کند

هر زوجی که رد می‌کنیم، به‌طور اثبات‌پذیر جواب نیست، چون آرایه مرتب است. وقتی sum < target است و lo را جلو می‌بریم، در واقع می‌گوییم «همهٔ زوج‌هایی که a[lo] را با عناصر کوچک‌تر از a[hi] جفت می‌کردند از این هم کوچک‌تر بودند، پس بی‌فایده‌اند». همین «اثبات رد کردن» است که به ما اجازه می‌دهد O(n²) را به O(n) تبدیل کنیم — نه یک ترفند، بلکه یک استدلال.

3Sum (تلهٔ کلاسیک حذف تکراری‌ها)

حالا خواسته سه‌تایی است که مجموعش صفر شود. ترفند این است که یک عنصر را «لنگر» (anchor) بگیریم و بین بقیهٔ آرایه، دنبال دو اشاره‌گرِ Two Sum بگردیم که مکملش را بسازند.

List<List<Integer>> threeSum(int[] nums) {
    Arrays.sort(nums);
    List<List<Integer>> res = new ArrayList<>();
    for (int i = 0; i < nums.length - 2; i++) {
        if (i > 0 && nums[i] == nums[i - 1]) continue;   // از لنگرهای تکراری بگذر
        if (nums[i] > 0) break;                          // با همه مثبت‌ها به ۰ نمی‌رسیم
        int lo = i + 1, hi = nums.length - 1;
        while (lo < hi) {
            int sum = nums[i] + nums[lo] + nums[hi];
            if (sum < 0) lo++;
            else if (sum > 0) hi--;
            else {
                res.add(List.of(nums[i], nums[lo], nums[hi]));
                while (lo < hi && nums[lo] == nums[lo + 1]) lo++;  // از lowهای تکراری بگذر
                while (lo < hi && nums[hi] == nums[hi - 1]) hi--;  // از highهای تکراری بگذر
                lo++; hi--;
            }
        }
    }
    return res;
}

زمان O(n²) (مرتب‌سازی O(n log n) است اما پیمایش تودرتو غالب است)، حافظهٔ اضافی O(1) جدا از خروجی.

تلهٔ رَدّکنندهٔ مصاحبه

اگر فقط لنگر تکراری را رد کنی و فراموش کنی که بعد از پیدا کردن یک جواب هم باید lo و hi را از تکراری‌ها بپرانی، جواب‌های تکراری تولید می‌کنی و مصاحبه را می‌بازی. باید در هر سه موقعیت (لنگر، low، high) تکراری‌ها را رد کنی. این خطِ if (nums[i] > 0) break; هم یک هرسِ ظریف است: اگر خودِ لنگر مثبت شد، چون آرایه مرتب است بقیه هم مثبت‌اند و مجموع سه‌تای مثبت هرگز صفر نمی‌شود.


۲. پنجره لغزان (sliding window)

پنجرهٔ کشویی روی یک قطار

تصور کن جلوی یک قطارِ در حال حرکت ایستاده‌ای و از پشت یک پنجرهٔ کشویی، واگن‌ها را نگاه می‌کنی. لبهٔ راستِ پنجره را جلو می‌بری تا واگن جدید وارد کادر شود، و هر وقت کادر «شلوغ» شد (قیدی نقض شد)، لبهٔ چپ را جلو می‌کشی تا واگن‌های اول از کادر خارج شوند. هر واگن دقیقاً یک بار وارد و یک بار خارج می‌شود — به همین دلیل کل کار O(n) می‌ماند، نه O(n²).

تشخیص: زیرآرایه/زیررشتهٔ «پیوسته» و خواستنِ طولانی‌ترین/کوتاه‌ترین/همهٔ پنجره‌هایی که یک قید را ارضا می‌کنند. پنجرهٔ [left, right] از سمت راست گسترش و از سمت چپ جمع می‌شود؛ هر اندیس حداکثر یک بار وارد و خارج می‌شود → O(n). کلمهٔ جادویی «پیوسته» (contiguous) است.

طولانی‌ترین زیررشته بدون کاراکتر تکراری

int lengthOfLongestSubstring(String s) {
    int[] last = new int[128];        // آخرین اندیس+۱ دیده‌شده برای هر کاراکتر ASCII، ۰ = ندیده
    int left = 0, best = 0;
    for (int right = 0; right < s.length(); right++) {
        char c = s.charAt(right);
        left = Math.max(left, last[c]);   // چپ را از پشت رخداد قبلی بپران
        best = Math.max(best, right - left + 1);
        last[c] = right + 1;              // اندیس+۱ ذخیره کن تا ۰ یعنی «ندیده»
    }
    return best;
}

اینجا right لبهٔ جلویی پنجره است و هر بار یک کاراکتر تازه می‌بلعد. اگر آن کاراکتر را قبلاً دیده باشیم، left را می‌پرانیم تا درست بعد از رخداد قبلی‌اش بنشیند — یعنی پنجره را از سمت چپ کوتاه می‌کنیم تا دوباره «بدون تکرار» شود. ترفندِ اندیس+۱ هم زیرکانه است: چون آرایهٔ last با صفر مقداردهی می‌شود، اگر اندیس واقعی را ذخیره کنیم، کاراکترِ خانهٔ صفر با «ندیده» قاطی می‌شود؛ با ذخیرهٔ اندیس+۱، عددِ صفر بی‌ابهام یعنی «هرگز ندیدم».

کوچک‌ترین پنجرهٔ پوشاننده (پنجرهٔ متغیر با نقشهٔ نیاز)

String minWindow(String s, String t) {
    int[] need = new int[128];
    for (char c : t.toCharArray()) need[c]++;
    int required = t.length();                 // مجموع کاراکترهایی که هنوز باید تأمین شوند
    int left = 0, bestLen = Integer.MAX_VALUE, bestL = 0;
    for (int right = 0; right < s.length(); right++) {
        if (need[s.charAt(right)]-- > 0) required--;   // یک کاراکتر موردنیاز مصرف شد
        while (required == 0) {                        // پنجره معتبر است -> سعی کن جمعش کنی
            if (right - left + 1 < bestLen) { bestLen = right - left + 1; bestL = left; }
            if (need[s.charAt(left)]++ == 0) required++; // رها کردن یک کاراکتر موردنیاز
            left++;
        }
    }
    return bestLen == Integer.MAX_VALUE ? "" : s.substring(bestL, bestL + bestLen);
}

O(|s| + |t|). اینجا need[c] مثل یک «لیست خرید» است: چند تا از هر کاراکتر لازم داری. هر بار که کاراکتری را می‌بلعی، از لیست خرید کم می‌کنی؛ وقتی required به صفر رسید یعنی همهٔ اقلام لیست تأمین شده و پنجره «معتبر» است، پس شروع می‌کنی به جمع کردن از چپ تا کوچک‌ترین پنجرهٔ معتبر را پیدا کنی.

چرا اجازه می‌دهیم need منفی شود؟

ظرافت کار در این است که need برای کاراکترهای مازاد منفی می‌شود. یعنی اگر سه تا a داری ولی فقط یکی لازم داشتی، need['a'] می‌رود روی -2. حالا بررسی‌های > 0 و == 0 به‌طور رایگان «موردنیاز» را از «اضافه» تشخیص می‌دهند: فقط وقتی مقدار پیش از کاهش > 0 بوده، یعنی کاراکترِ واقعاً لازم را مصرف کرده‌ای. علامتِ عدد، خودش داستانِ «آیا این کاراکتر را برآورده کرده‌ام» را حمل می‌کند.

پنجرهٔ اندازهٔ ثابت (مثلاً بیشینه مجموع k عنصر متوالی) پسرعموی ساده‌تر است: عنصر ورودی را جمع کن، عنصر خروجی را کم کن، بدون حلقهٔ while داخلی. چون طول پنجره ثابت است، اصلاً نیازی به جمع کردنِ متغیر نداری.


۳. اشاره‌گر تند و کند (فلوید)

دو دونده روی یک پیستِ دایره‌ای

دو دونده را روی یک پیست بگذار؛ یکی هر ثانیه یک قدم می‌دود (کند)، دیگری دو قدم (تند). اگر پیست یک مسیرِ مستقیم و بی‌انتها باشد، تندرو برای همیشه جلو می‌زند و هیچ‌وقت به کندرو نمی‌رسد. اما اگر پیست یک حلقه داشته باشد، تندرو مثل دویدن روی زمینِ ورزشگاه، بالاخره از پشت به کندرو می‌رسد و برخورد می‌کنند. همین «برخورد یا نبردنِ برخورد» به تو می‌گوید حلقه هست یا نه — و هیچ حافظهٔ اضافه‌ای نمی‌خواهد.

تشخیص: لیست‌های پیوندی — تشخیص حلقه، یافتن وسط، یافتن نقطهٔ ورود حلقه — یا «تشخیص تکرار در یک دنبالهٔ تابعی» (LeetCode 287) با حافظهٔ O(1). هر جا شنیدی «بدون حافظهٔ اضافه» و «لیست پیوندی»، ذهنت باید بپرد سراغ فلوید.

تشخیص حلقه + نقطهٔ ورود

ListNode detectCycle(ListNode head) {
    ListNode slow = head, fast = head;
    while (fast != null && fast.next != null) {
        slow = slow.next;            // +۱ در هر گام
        fast = fast.next.next;       // +۲ در هر گام
        if (slow == fast) {          // برخورد کردند -> حلقه وجود دارد
            ListNode p = head;
            while (p != slow) { p = p.next; slow = slow.next; }  // فاز دوم فلوید
            return p;                // نقطهٔ ورود حلقه
        }
    }
    return null;                     // fast به null رسید -> حلقه‌ای نیست
}

زمان O(n)، حافظه O(1). فاز اول برخورد را تشخیص می‌دهد. فاز دوم یک شعبده‌بازیِ زیبای ریاضی است: پس از برخورد، فاصلهٔ head تا نقطهٔ ورود دقیقاً برابر است با فاصلهٔ نقطهٔ برخورد تا ورود. پس یک اشاره‌گر را به head برمی‌گردانی و هر دو را تک‌گام جلو می‌بری؛ درست سرِ نقطهٔ ورودِ حلقه به هم می‌رسند. (اثبات کاملش را در سؤال ۴ بخش مصاحبه داریم.)

یافتن وسط

ListNode middle(ListNode head) {
    ListNode slow = head, fast = head;
    while (fast != null && fast.next != null) { slow = slow.next; fast = fast.next.next; }
    return slow;   // برای طول زوج، وسط دوم را برمی‌گرداند
}

همان ایده در سبک ساده‌تر: وقتی تندرو به ته لیست می‌رسد، کندرو دقیقاً وسط است — چون نصفِ مسافت را رفته. این ترفندِ «وسط را در یک عبور پیدا کن» پایهٔ مرتب‌سازیِ ادغامیِ لیست پیوندی و خیلی مسائل دیگر است.


۴. جمع پیشوندی (و آرایهٔ تفاضلی)

کیلومترشمارِ ماشین

تصور کن می‌خواهی بدانی از تابلوی کیلومترِ ۱۲۰ تا تابلوی ۳۵۰ چقدر رانده‌ای. لازم نیست دوباره آن مسیر را بروی و متر کنی؛ فقط عددِ کیلومترشمار در تابلوی ۳۵۰ منهای عددش در تابلوی ۱۲۰. کیلومترشمار یک «جمع تجمعی» است که از قبل ذخیره‌اش کرده‌ای، پس جوابِ هر بازه فقط یک تفریق است — یعنی O(1).

تشخیص: کوئری‌های زیادِ «مجموع بازهٔ [i, j]»، یا «تعداد زیرآرایه‌هایی با مجموع k». مجموع‌های تجمعی را از پیش محاسبه کن تا هر پاسخ بازه O(1) شود.

مجموع زیرآرایه برابر K

int subarraySum(int[] nums, int k) {
    Map<Integer, Integer> count = new HashMap<>();
    count.put(0, 1);                 // پیشوند خالی مجموعش ۰ است، یک بار رخ می‌دهد
    int prefix = 0, result = 0;
    for (int n : nums) {
        prefix += n;
        // تعداد پیشوندهای قبلی P که prefix - P == k
        result += count.getOrDefault(prefix - k, 0);
        count.merge(prefix, 1, Integer::sum);
    }
    return result;
}

منطقش را کیلومترشمارگونه ببین: اگر «مجموعِ تجمعی تا اینجا» برابر prefix است، آن‌وقت هر زیرآرایه‌ای که مجموعش k می‌شود، از یک نقطه‌ای شروع شده که مجموعِ تجمعی‌اش prefix - k بوده. پس فقط می‌شماریم قبلاً چند بار به مقدارِ تجمعی prefix - k رسیده‌ایم. زمان و حافظه O(n).

چرا اینجا پنجرهٔ لغزان جواب نمی‌دهد؟

پنجرهٔ لغزان فرض می‌کند بزرگ‌کردن پنجره مجموع را یکنوا بزرگ می‌کند. اما اگر آرایه عدد منفی داشته باشد، اضافه‌کردنِ یک عنصر می‌تواند مجموع را کم کند، پس دیگر یکنوا نیست و پنجره می‌شکند. جمع پیشوندی چون فراوانیِ پیشوندها را می‌شمارد (نه یکنواییِ مجموع)، صرف‌نظر از علامت درست کار می‌کند. این تفاوت را زیاد در مصاحبه می‌پرسند.

جمع پیشوندی دوبعدی کوئری‌های مجموع زیرماتریس را پس از پیش‌پردازش O(mn) در O(1) پاسخ می‌دهد: sum(r1,c1,r2,c2) = P[r2+1][c2+1] - P[r1][c2+1] - P[r2+1][c1] + P[r1][c1]. (همان «تفریقِ کیلومترشمار»، این بار در دو بُعد، با اصل شمول-طرد.)

آرایهٔ تفاضلی (difference array) برعکسِ جمع پیشوندی است: برای اعمال چندین به‌روزرسانی افزایشی روی بازه‌ها و سپس یک بار خواندن، در l مقدار +delta و در r+1 مقدار -delta بگذار، سپس جمع پیشوندی بگیر. هر به‌روزرسانی O(1) است. مثل اینکه فقط «شروع و پایانِ اضافه‌شدن» را علامت بزنی و آخرِ کار همه را با هم جمع بزنی.


۵. جست‌وجوی دودویی — از جمله روی فضای پاسخ

بازیِ حدس عدد

یکی به عددی بین ۱ تا ۱۰۰۰ فکر کرده و تو باید حدسش بزنی؛ بعد از هر حدس فقط می‌گوید «بالاتر» یا «پایین‌تر». تو هر بار وسطِ بازهٔ باقی‌مانده را می‌گویی و نصفِ اعداد را حذف می‌کنی. در ده حدس تمام می‌شود. حالا نکتهٔ سنیوری: لازم نیست حتماً یک «آرایهٔ مرتب» داشته باشی. فقط کافی است یک مرز وجود داشته باشد — تا نقطه‌ای «نه» و از آنجا به بعد «بله». روی همان مرز جست‌وجوی دودویی می‌کنی.

تشخیص: فضای جست‌وجوی یکنوا. یا یک آرایهٔ مرتب، یا یک گزارهٔ (predicate) f(x) که به شکل نادرست-نادرست-…-درست-درست است (یک مرز). گزاره یعنی تابعی که فقط «بله/خیر» برمی‌گرداند. شکل دوم — «جست‌وجوی دودویی روی پاسخ» — عامل تمایز سطح سنیور است.

قالب lower-bound (این را حفظ کن)

// اولین اندیس در [lo, hi) که pred(index) درست است؛ اگر هیچ‌کدام، hi.
int lowerBound(int lo, int hi, IntPredicate pred) {
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;   // از سرریز int جلوگیری می‌کند (برخلاف (lo+hi)/2)
        if (pred.test(mid)) hi = mid;   // پاسخ mid است یا سمت چپ
        else lo = mid + 1;              // پاسخ اکیداً سمت راست است
    }
    return lo;
}

این قالب «اولین جایی که گزاره «بله» می‌شود» را پیدا می‌کند — یعنی همان مرزِ نادرست→درست. اگر pred(mid) درست بود، شاید mid خودش جواب باشد، پس آن را از دست نمی‌دهیم و hi = mid؛ اگر نادرست بود، جواب حتماً سمت راست است، پس lo = mid + 1.

کوکو و خوردن موز (جست‌وجو روی پاسخ)

حالا جادوی واقعی: صورتِ مسئله اصلاً آرایهٔ مرتب ندارد. کوکو n توده موز دارد و باید ظرف h ساعت همه را بخورد؛ کمترین سرعتِ خوردن (موز در ساعت) را می‌خواهیم. «کمترین سرعتی که کار را تمام کند» یعنی جست‌وجو روی خودِ پاسخ.

// کمترین سرعت خوردن برای تمام کردن همهٔ توده‌ها ظرف h ساعت.
int minEatingSpeed(int[] piles, int h) {
    int lo = 1, hi = Arrays.stream(piles).max().getAsInt();
    while (lo < hi) {
        int speed = lo + (hi - lo) / 2;
        long hours = 0;
        for (int p : piles) hours += (p + speed - 1) / speed;  // سقف(p/speed)
        if (hours <= h) hi = speed;    // شدنی -> کندتر را امتحان کن
        else lo = speed + 1;           // خیلی کند -> سریع‌تر لازم است
    }
    return lo;
}

O(n log maxPile). الگو: feasible(x) را تعریف کن (که در x یکنواست — هرچه سریع‌تر بخوری، حتماً زودتر تمام می‌کنی)، و کوچک‌ترین/بزرگ‌ترین x شدنی را دودویی جست‌وجو کن. همین اسکلت «تقسیم آرایه با کمترین مجموع بیشینه»، «ظرفیت حمل بسته‌ها در D روز» و «کمینه‌سازی بیشینهٔ فاصله» را هم حل می‌کند.

تلهٔ سرریز و کدگذاریِ binarySearch

همیشه mid = lo + (hi - lo) / 2 بنویس، نه (lo + hi) / 2. چون اگر lo و hi هر دو نزدیک Integer.MAX_VALUE باشند، lo + hi از ظرفیتِ int سرریز می‌کند و یک عددِ منفی و در نتیجه ArrayIndexOutOfBoundsException می‌دهد. شکل تفریقی هرگز سرریز نمی‌کند. و در جاوا، Arrays.binarySearch هنگام نیافتن، -(نقطهٔ درج) - 1 برمی‌گرداند — این کدگذاری را بدان تا در LIS به دردت بخورد.


۶. بازگشت‌پذیری (backtracking)

کاوش در یک هزارتو با نخِ آریادنه

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

تشخیص: برشماری همهٔ زیرمجموعه‌ها/جایگشت‌ها/ترکیب‌ها، یا ارضای قید (N-Queens، سودوکو، جست‌وجوی کلمه). یک نامزد را به‌تدریج بساز، و وقتی شاخه‌ای تمام شد آخرین انتخاب را باطل کن (undo). پیچیدگی وابسته به خروجی است: زیرمجموعه‌ها O(2ⁿ)، جایگشت‌ها O(n!). یعنی خودِ تعدادِ جواب‌ها این‌قدر است و کمتر از آن ممکن نیست.

زیرمجموعه‌ها

List<List<Integer>> subsets(int[] nums) {
    List<List<Integer>> res = new ArrayList<>();
    backtrack(nums, 0, new ArrayList<>(), res);
    return res;
}
void backtrack(int[] nums, int start, List<Integer> path, List<List<Integer>> res) {
    res.add(new ArrayList<>(path));          // هر گره یک زیرمجموعهٔ معتبر است؛ کپی‌اش کن
    for (int i = start; i < nums.length; i++) {
        path.add(nums[i]);                    // انتخاب کن
        backtrack(nums, i + 1, path, res);    // کاوش (i+1 از استفادهٔ مجدد/جابه‌جایی جلوگیری می‌کند)
        path.remove(path.size() - 1);         // باطل کن (گام backtrack)
    }
}

سه خطِ داخل حلقه دقیقاً همان سه‌گانهٔ هزارتوست: انتخاب کن (path.addکاوش کن (فراخوانی بازگشتی)، باطل کن (path.remove). پارامترِ start تضمین می‌کند رو به جلو برویم و هر زیرمجموعه یک بار ساخته شود، نه با ترتیب‌های مختلف.

جایگشت‌ها

List<List<Integer>> permute(int[] nums) {
    List<List<Integer>> res = new ArrayList<>();
    boolean[] used = new boolean[nums.length];
    dfs(nums, used, new ArrayList<>(), res);
    return res;
}
void dfs(int[] nums, boolean[] used, List<Integer> path, List<List<Integer>> res) {
    if (path.size() == nums.length) { res.add(new ArrayList<>(path)); return; }
    for (int i = 0; i < nums.length; i++) {
        if (used[i]) continue;
        used[i] = true; path.add(nums[i]);
        dfs(nums, used, path, res);
        used[i] = false; path.remove(path.size() - 1);   // هر دو تغییر را باطل کن
    }
}

فرقِ جایگشت با زیرمجموعه این است که ترتیب مهم است و هر عنصر باید یک بار در هر جایگشت بیاید. آرایهٔ used مثل یک تابلوی «رزرو شده» است: نشان می‌دهد کدام عناصر همین الان در مسیرِ ساخته‌شده حاضرند. توجه کن که موقع باطل‌کردن باید هر دو تغییر (used[i] و path) را برگردانی.

N-Queens (علامت‌گذاری ستون + دو قطر)

int totalNQueens(int n) {
    return place(n, 0, new boolean[n], new boolean[2*n], new boolean[2*n]);
}
// col[c]، diag[r+c]، anti[r-c+n] خطوط اشغال‌شده را ردیابی می‌کنند.
int place(int n, int row, boolean[] col, boolean[] diag, boolean[] anti) {
    if (row == n) return 1;
    int count = 0;
    for (int c = 0; c < n; c++) {
        int d = row + c, a = row - c + n;
        if (col[c] || diag[d] || anti[a]) continue;      // تحت حمله -> هرس کن
        col[c] = diag[d] = anti[a] = true;
        count += place(n, row + 1, col, diag, anti);
        col[c] = diag[d] = anti[a] = false;              // backtrack
    }
    return count;
}

ترفند r+c و r-c+n هر قطر را به یک اندیس یکتا نگاشت می‌کند تا بررسی حمله O(1) شود. چرا؟ چون همهٔ خانه‌های روی یک قطرِ اصلی، مجموعِ سطر+ستونشان یکسان است، و روی یک قطرِ فرعی، تفاضلشان یکسان (که با +n مثبتش می‌کنیم تا اندیسِ آرایه شود). پس به‌جای پیمایشِ قطر، فقط یک خانهٔ boolean را چک می‌کنی.

حذف تکراری‌ها وقتی ورودی عناصر تکراری دارد

برای مسائلی مثل subsetsWithDup یا permuteUnique، اول آرایه را مرتب کن، سپس این خط را بگذار: if (i > start && nums[i] == nums[i-1]) continue;. این یعنی «در همین سطحِ درخت، مقداری را که همین‌الان امتحان کردم دوباره امتحان نمی‌کنم». دقت کن شرط i > start است نه i > 0 — چون در سطحِ عمیق‌تر (فراخوانیِ بعدی) اجازه داری همان مقدار را دوباره بگذاری؛ فقط هم‌سطح‌هایِ تکراری ممنوع‌اند.


۷. حریصانه (greedy)

پُر کردن کولهٔ کوهنوردی

داری برای کوه کوله می‌بندی و هر بار برمی‌داری «به‌نظر بهترین» چیز را می‌گذاری تو کوله. گاهی این کار جواب می‌دهد و سبک‌ترین کولهٔ ممکن را می‌بندی؛ گاهی هم یک تصمیمِ زودهنگام (مثلاً برداشتنِ یک قوطیِ بزرگِ کنسرو) جا را برای دو چیزِ مفیدترِ کوچک تنگ می‌کند. تفاوتِ این دو حالت را فقط با اثبات می‌فهمی، نه با حس. حریصانه یعنی «همیشه بهترین انتخابِ لحظه‌ای»، ولی فقط وقتی مجاز است که ثابت کنی این کار هرگز تو را به بن‌بست نمی‌برد.

تشخیص: یک انتخاب محلیِ بهینه به‌طور اثبات‌پذیر به بهینهٔ سراسری می‌رسد. بخش سخت، اثبات ناوردای حریصانه است (استدلال تبادلی / exchange argument) — اگر نتوانی اثبات کنی، احتمالاً DP است.

Jump Game (دسترسی‌پذیری)

boolean canJump(int[] nums) {
    int farthest = 0;
    for (int i = 0; i < nums.length; i++) {
        if (i > farthest) return false;             // حتی به i هم نمی‌رسیم
        farthest = Math.max(farthest, i + nums[i]);  // برد را حریصانه گسترش بده
    }
    return true;
}

اینجا حریصانه بودن یعنی: در هر خانه، فقط «دورترین جایی که تا اینجا می‌توانم برسم» را نگه می‌داریم. اگر شمارندهٔ i از دورترین برد جلو زد، یعنی یک شکاف پُرنشدنی هست و هرگز به آنجا نمی‌رسیم.

بازه‌های ناهم‌پوشان (زمان‌بندی بازه — حریص بر اساس زمان پایان)

// کمترین تعداد بازه برای حذف تا بقیه هم‌پوشانی نداشته باشند.
int eraseOverlapIntervals(int[][] intervals) {
    Arrays.sort(intervals, (x, y) -> Integer.compare(x[1], y[1])); // بر اساس پایان
    int end = Integer.MIN_VALUE, removed = 0;
    for (int[] iv : intervals) {
        if (iv[0] >= end) end = iv[1];   // نگهش دار: با آخرین نگه‌داشته هم‌پوشانی ندارد
        else removed++;                  // هم‌پوشانی دارد -> آن‌که دیرتر تمام می‌شود را بینداز
    }
    return removed;
}
چرا مرتب‌سازی بر اساس زمانِ پایان؟

این استدلالِ تبادلیِ کلاسیک است: بازه‌ای که زودتر تمام می‌شود بیشترین جا را برای بقیه باقی می‌گذارد. تصور کن چند جلسه داری و می‌خواهی بیشترین تعدادِ ناهم‌پوشان را برگزار کنی؛ همیشه آن جلسه‌ای را نگه دار که زودتر تمام شود، چون اتاق را زودتر آزاد می‌کند. می‌شود ثابت کرد نگه‌داشتنِ زودترین‌پایان هرگز از هر انتخابِ دیگری بدتر نیست. O(n log n).


۸. برنامه‌ریزی پویا (DP)

ساختنِ یک برجِ لِگو با قطعاتِ نیمه‌ساختهٔ ذخیره‌شده

تصور کن داری یک برجِ بزرگِ لِگو می‌سازی و می‌بینی خیلی از تکه‌های میانی، بارها و بارها تکرار می‌شوند. آدمِ زرنگ هر تکهٔ نیمه‌ساخته را یک بار می‌سازد و در جعبه‌ای برچسب‌دار می‌گذارد؛ دفعهٔ بعد که لازمش شد، به‌جای ساختنِ دوباره، از جعبه برش می‌دارد. برنامه‌ریزی پویا همین است: مسئله را به زیرمسئله‌ها می‌شکنی، هر زیرمسئله را یک بار حل می‌کنی و جوابش را ذخیره می‌کنی (memoization / جدول)، و از ترکیبشان جوابِ بزرگ را می‌سازی.

تشخیص: زیرساختار بهینه + زیرمسئله‌های هم‌پوشان. یعنی جوابِ بزرگ از جوابِ بهینهٔ تکه‌های کوچک‌تر ساخته می‌شود، و آن تکه‌ها بارها تکرار می‌شوند. سیگنال‌ها: «تعداد راه‌ها را بشمار»، «کمترین/بیشترین هزینه/طول»، «آیا می‌توانی برسی/افراز کنی». سه چیز را تعریف کن — حالت (state) (چه چیزی یک زیرمسئله را توصیف می‌کند)، انتقال (transition) (چطور از زیرمسئله‌های کوچک‌تر به این می‌رسی)، و حالت پایه — و تصمیم بگیر بالا-به-پایین (بازگشت با memoization) یا پایین-به-بالا (جدول).

یک‌بعدی — پله‌ها / سارق خانه

// سارق خانه: بیشینه مجموع بدون انتخاب دو خانهٔ مجاور.
int rob(int[] nums) {
    int take = 0, skip = 0;              // بهترین حالت اگر خانهٔ قبلی را دزدیده/ندزدیده بودیم
    for (int n : nums) {
        int newTake = skip + n;          // این را بدزد -> پس باید قبلی را رد کرده باشیم
        skip = Math.max(skip, take);     // این را رد کن -> بهترینِ حالت‌های قبلی
        take = newTake;
    }
    return Math.max(take, skip);
}

زمان O(n)، حافظه O(1). اینجا کلِ «جدولِ DP» به دو متغیرِ غلتان (rolling) خلاصه شده، چون هر حالت فقط به حالتِ قبلی وابسته است. take یعنی «بهترین غنیمت اگر این خانه را بدزدم»، skip یعنی «بهترین غنیمت اگر ردش کنم». چون نمی‌شود دو خانهٔ مجاور را دزدید، برای دزدیدنِ این خانه باید قبلی را رد کرده باشی.

کوله‌پشتی ۰/۱ (بهینه‌سازی حافظه از دوبعدی به یک‌بعدی)

int knapsack(int[] weight, int[] value, int cap) {
    int[] dp = new int[cap + 1];         // dp[w] = بهترین ارزش با ظرفیت w
    for (int i = 0; i < weight.length; i++)
        for (int w = cap; w >= weight[i]; w--)   // ظرفیت را رو به پایین بپیما
            dp[w] = Math.max(dp[w], dp[w - weight[i]] + value[i]);
    return dp[cap];
}

زمان O(n·cap)، حافظه O(cap).

تلهٔ حیاتی: جهتِ حلقه

حلقهٔ داخلی برای کوله‌پشتیِ ۰/۱ باید رو به پایین باشد (هر قلم فقط یک بار استفاده می‌شود). چرا؟ چون وقتی رو به پایین می‌روی، dp[w - weight[i]] هنوز مقدارِ قبل از افزودنِ قلمِ فعلی را دارد، پس همان قلم دوبار حساب نمی‌شود. برای کوله‌پشتیِ نامحدود / خردکردن سکه، برعکس، رو به بالا بپیما تا یک قلم بتواند در همان گذر دوباره استفاده شود. بدترین بخشِ این باگ این است که برنامه crash نمی‌کند — فقط بی‌سروصدا جوابِ غلط می‌دهد.

خردکردن سکه (کمترین سکه، نامحدود)

int coinChange(int[] coins, int amount) {
    int[] dp = new int[amount + 1];
    Arrays.fill(dp, amount + 1);         // نگهبان = «بی‌نهایت»
    dp[0] = 0;
    for (int coin : coins)
        for (int a = coin; a <= amount; a++)     // رو به بالا -> سکه‌ها قابل استفادهٔ مجدد
            dp[a] = Math.min(dp[a], dp[a - coin] + 1);
    return dp[amount] > amount ? -1 : dp[amount];
}

O(amount·coins). اینجا amount + 1 نقشِ «بی‌نهایت» را بازی می‌کند: چون هیچ راهِ درستی بیشتر از amount سکه نمی‌خواهد، هر مقداری بزرگ‌تر از آن یعنی «نشدنی». دقت کن این حلقه رو به بالاست — دقیقاً برعکسِ کوله‌پشتیِ ۰/۱ — چون هر سکه را می‌شود بارها استفاده کرد.

طولانی‌ترین زیردنبالهٔ صعودی (LIS با patience sorting در O(n log n))

int lengthOfLIS(int[] nums) {
    int[] tails = new int[nums.length];  // tails[i] = کوچک‌ترین دنبالهٔ صعودی به طول i+1
    int size = 0;
    for (int x : nums) {
        int i = Arrays.binarySearch(tails, 0, size, x);
        if (i < 0) i = -(i + 1);         // نقطهٔ درج را رمزگشایی کن
        tails[i] = x;                    // اولین tail بزرگ‌تر-مساوی x را جایگزین کن (یا گسترش بده)
        if (i == size) size++;
    }
    return size;                         // توجه: tails خودِ LIS نیست، فقط طولش را می‌دهد
}

راه‌حل DP با O(n²) (dp[i] = 1 + max dp[j] برای j<i و nums[j]<nums[i]) خوب است که اول گفته شود، سپس با آرایهٔ tails + جست‌وجوی دودویی به O(n log n) بهینه شود. اسمِ «patience sorting» از بازیِ ورق می‌آید: هر ورق را روی اولین دسته‌ای می‌گذاری که ورقِ رویش از آن بزرگ‌تر است. اینجا هم دقیقاً همان کار را با binarySearch می‌کنیم؛ و یادت باشد چون در اینجا مقدار پیدا نمی‌شود، binarySearch عددِ منفیِ کدگذاری‌شده می‌دهد که با -(i+1) رمزگشایی‌اش می‌کنیم.

فاصلهٔ ویرایش (DP دوبعدی)

int minDistance(String a, String b) {
    int m = a.length(), n = b.length();
    int[][] dp = new int[m + 1][n + 1];
    for (int i = 0; i <= m; i++) dp[i][0] = i;   // کل پیشوند a را حذف کن
    for (int j = 0; j <= n; j++) dp[0][j] = j;   // کل پیشوند b را درج کن
    for (int i = 1; i <= m; i++)
        for (int j = 1; j <= n; j++)
            dp[i][j] = a.charAt(i-1) == b.charAt(j-1)
                ? dp[i-1][j-1]                                  // کاراکترها یکی‌اند -> رایگان
                : 1 + Math.min(dp[i-1][j-1],                   // جایگزینی
                      Math.min(dp[i-1][j], dp[i][j-1]));       // حذف / درج
    return dp[m][n];
}

زمان و حافظه O(mn)؛ با نگه‌داشتن دو سطر به حافظهٔ O(min(m,n)) کاهش می‌یابد.

چرا فاصلهٔ ویرایش قالبِ طلایی است

اگر انتقالِ روی این شبکه را یاد بگیری، رایگان چند مسئلهٔ دیگر را هم بلد شده‌ای: LCS (طولانی‌ترین زیردنبالهٔ مشترک)، طولانی‌ترین زیررشتهٔ مشترک، و تطبیقِ regex/wildcard. همه‌شان یک جدولِ دوبعدی‌اند که در هر خانه می‌پرسی «اگر این دو کاراکتر یکی بودند چه؟ اگر نبودند، بهترین حرکت (درج/حذف/جایگزینی) کدام است؟». یک شبکه، ده‌ها مسئله.


۹. بازه‌ها (intervals)

تقویمِ رزروِ اتاق جلسه

یک عالمه درخواستِ رزروِ اتاق داری، هرکدام با «ساعت شروع» و «ساعت پایان». اولین کاری که هر منشیِ باتجربه می‌کند این است که همه را روی تقویم بر اساس زمانِ شروع ردیف می‌کند و بعد از بالا به پایین می‌رود تا ببیند کدام‌ها روی هم می‌افتند. همین «مرتب کن، بعد جاروب کن» جانِ همهٔ مسائلِ بازه است.

تشخیص: ورودی فهرستی از زوج‌های [start, end] است و باید ادغام کنی، درج کنی، هم‌پوشانی‌ها را بشماری، یا شکاف‌های آزاد بیابی. اولین حرکت جهانی مرتب‌سازی بر اساس start است (گاهی بر اساس end برای زمان‌بندی حریص)، سپس جاروب کن.

ادغام بازه‌ها

int[][] merge(int[][] intervals) {
    Arrays.sort(intervals, (a, b) -> Integer.compare(a[0], b[0]));  // بر اساس start
    List<int[]> out = new ArrayList<>();
    int[] cur = intervals[0];
    for (int i = 1; i < intervals.length; i++) {
        if (intervals[i][0] <= cur[1])                 // هم‌پوشانی
            cur[1] = Math.max(cur[1], intervals[i][1]); // بازهٔ جاری را گسترش بده
        else { out.add(cur); cur = intervals[i]; }      // شکاف -> ذخیره و شروع جدید
    }
    out.add(cur);
    return out.toArray(new int[0][]);
}

چون بر اساس شروع مرتب کرده‌ای، فقط کافی است بازهٔ جاری را نگه داری: اگر بازهٔ بعدی قبل از پایانِ آن شروع شود، روی هم افتاده‌اند و کافی است پایانِ جاری را (تا هرکدام دورتر است) گسترش دهی؛ وگرنه یک شکاف است، جاری را ذخیره و بازهٔ نو را شروع کن.

اتاق‌های جلسه II (کمترین اتاق = بیشینهٔ هم‌پوشانی هم‌زمان)

int minMeetingRooms(int[][] meetings) {
    int n = meetings.length;
    int[] starts = new int[n], ends = new int[n];
    for (int i = 0; i < n; i++) { starts[i] = meetings[i][0]; ends[i] = meetings[i][1]; }
    Arrays.sort(starts); Arrays.sort(ends);
    int rooms = 0, maxRooms = 0, e = 0;
    for (int s = 0; s < n; s++) {
        while (e < n && ends[e] <= starts[s]) { rooms--; e++; }  // یک جلسه اتاقی را آزاد کرد
        rooms++;                                                  // این جلسه یک اتاق می‌گیرد
        maxRooms = Math.max(maxRooms, rooms);
    }
    return maxRooms;
}

ایدهٔ زیبا این است: کمترین تعدادِ اتاق‌های لازم دقیقاً برابرِ بیشترین تعداد جلسه‌ای است که هم‌زمان در جریان‌اند. پس دو جریانِ رویدادِ مرتب می‌سازیم — شروع‌ها و پایان‌ها — و مثل تماشای دَرِ ورودی و خروجی، هر «شروع» یک اتاق می‌گیرد و هر «پایان» یکی آزاد می‌کند؛ بیشینهٔ اتاق‌های اشغال‌شده جواب است. همین ایده (یا یک min-heap از زمان‌های پایان) به هر پرسشِ «بیشینهٔ هم‌زمان» تعمیم می‌یابد. O(n log n).


۱۰. پشتهٔ یکنوا (monotonic stack)

صفِ منتظرانِ «یک نفرِ قدبلندتر»

تصور کن آدم‌ها پشت‌سرِ هم رد می‌شوند و هرکدام منتظرِ «اولین نفرِ بعدی که از خودش قدبلندتر باشد» است. یک صف از منتظرها نگه می‌داری که از جلو به عقب، قدها کم می‌شود. هر تازه‌واردِ قدبلند، هر کسِ کوتاه‌ترِ توی صف را «آزاد» می‌کند (چون جوابِ منتظرِ کوتاه، همین تازه‌وارد است) و بعد خودش می‌ایستد ته صف. هر آدم دقیقاً یک بار وارد صف و یک بار خارج می‌شود.

تشخیص: «عنصر بزرگ‌تر/کوچک‌تر بعدی»، «چقدر تا یک مقدار بزرگ‌تر»، مساحت مستطیل در هیستوگرام، span سهام. پشته‌ای نگه دار که مرتب بماند (صعودی یا نزولی)؛ هر عنصر یک بار push و یک بار pop می‌شود → O(n). کلمهٔ «یکنوا» یعنی همان پشته‌ای که ترتیبش را حفظ می‌کند.

دماهای روزانه (بزرگ‌تر بعدی)

int[] dailyTemperatures(int[] t) {
    int[] answer = new int[t.length];
    Deque<Integer> stack = new ArrayDeque<>();   // اندیس‌های یک دنبالهٔ دمای نزولی
    for (int i = 0; i < t.length; i++) {
        while (!stack.isEmpty() && t[i] > t[stack.peek()]) {
            int prev = stack.pop();              // امروز روزِ گرم‌ترِ بعدیِ prev است
            answer[prev] = i - prev;
        }
        stack.push(i);
    }
    return answer;   // اندیس‌های حل‌نشده ۰ می‌مانند
}

پشته اندیسِ روزهایی را نگه می‌دارد که هنوز منتظرِ «روزِ گرم‌تر» هستند و دماهایشان نزولی است. هر روزِ گرم‌تر که می‌آید، همهٔ روزهای منتظرِ سردتر را از پشته بیرون می‌کشد و فاصله‌شان را حساب می‌کند. آن‌هایی که تا آخر جوابی نگرفتند، صفر می‌مانند.

بزرگ‌ترین مستطیل در هیستوگرام

int largestRectangleArea(int[] h) {
    Deque<Integer> stack = new ArrayDeque<>();   // ارتفاع‌های صعودی (بر اساس اندیس)
    int best = 0;
    for (int i = 0; i <= h.length; i++) {
        int cur = (i == h.length) ? 0 : h[i];    // نگهبان ۰ در انتها پشته را تخلیه می‌کند
        while (!stack.isEmpty() && cur < h[stack.peek()]) {
            int height = h[stack.pop()];
            int leftBoundary = stack.isEmpty() ? -1 : stack.peek();
            int width = i - leftBoundary - 1;    // میله‌های اکیداً بین دو مرز
            best = Math.max(best, height * width);
        }
        stack.push(i);
    }
    return best;
}

O(n). کلیدِ فهمش این است: وقتی میله‌ای را pop می‌کنیم، i اولین کوچک‌ترِ سمت راستش است و رأس جدید پشته اولین کوچک‌ترِ سمت چپش. پس فاصلهٔ بینِ این دو مرز، دقیقاً بیشینه عرضی است که آن میله در آن، ارتفاعِ محدودکننده است — و مساحت می‌شود ارتفاع × عرض. آن نگهبانِ 0 در انتها هم برای این است که هر چیزی که تا آخر توی پشته مانده، حتماً تخلیه و حساب شود.


۱۱. K تای برتر / هیپ‌ها / quickselect

لیستِ ده‌تاییِ برترِ همیشه‌به‌روز

تصور کن داوریِ یک مسابقه‌ای و می‌خواهی همیشه فقط «ده امتیازِ برتر تا این لحظه» را کنار دستت داشته باشی. یک تختهٔ کوچک با ده جای خالی می‌گیری؛ هر شرکت‌کنندهٔ نو که می‌آید، اگر امتیازش از ضعیف‌ترینِ روی تخته بهتر بود، ضعیف‌ترین را پاک می‌کنی و او را می‌نویسی. هیچ‌وقت بیشتر از ده اسم روی تخته نداری. این دقیقاً همان min-heap با اندازهٔ K است: کوچک‌ترین (ضعیف‌ترین) همیشه دمِ دستت است تا سریع بیرونش کنی.

تشخیص: «K تای بزرگ‌ترین/کوچک‌ترین/پرتکرارترین»، «K نزدیک‌ترین به مبدأ»، «میانهٔ یک جریان». هیپ O(n log k) می‌دهد؛ quickselect برای یک کوئری K-اُم به‌طور میانگین O(n) می‌دهد. (هیپ یعنی همان درختِ کومه‌ای که کوچک‌ترین یا بزرگ‌ترین عنصر همیشه سرِ آن است.)

K عنصر پرتکرار (min-heap با اندازهٔ K)

int[] topKFrequent(int[] nums, int k) {
    Map<Integer, Integer> freq = new HashMap<>();
    for (int n : nums) freq.merge(n, 1, Integer::sum);
    // min-heap با کلید فراوانی؛ فقط K پرتکرارترین را نگه دار.
    PriorityQueue<Integer> heap = new PriorityQueue<>((a, b) -> freq.get(a) - freq.get(b));
    for (int key : freq.keySet()) {
        heap.offer(key);
        if (heap.size() > k) heap.poll();   // کم‌تکرارترینِ تاکنون را بیرون بینداز
    }
    int[] res = new int[k];
    for (int i = k - 1; i >= 0; i--) res[i] = heap.poll();
    return res;
}

O(n log k).

چرا min-heap اندازهٔ K، نه max-heap اندازهٔ N؟

نکتهٔ ظریف همین‌جاست: اگر دنبالِ K تای بزرگ می‌گردی، از یک min-heap با اندازهٔ K استفاده می‌کنی، نه max-heap با اندازهٔ N. چون آن تختهٔ کوچکِ ده‌جایی همیشه فقط K عنصر نگه می‌دارد و ضعیف‌ترین (کوچک‌ترین) سرِ آن است تا فوری بیرونش کنی. این حافظه را از O(n) به O(k) می‌رساند و روی جریانِ بی‌پایانِ داده هم کار می‌کند. (اگر K کوچک ثابت باشد، مرتب‌سازیِ سطلی بر اساس فراوانی حتی این را به O(n) می‌رساند.)

میانهٔ یک جریان داده (دو هیپ)

class MedianFinder {
    // lo: max-heap نیمهٔ کوچک‌تر؛ hi: min-heap نیمهٔ بزرگ‌تر.
    private final PriorityQueue<Integer> lo = new PriorityQueue<>(Collections.reverseOrder());
    private final PriorityQueue<Integer> hi = new PriorityQueue<>();

    public void addNum(int num) {
        lo.offer(num);
        hi.offer(lo.poll());                 // از میان lo عبور بده تا ترتیب درست بماند
        if (hi.size() > lo.size()) lo.offer(hi.poll());  // تعادل: اندازهٔ lo >= hi
    }
    public double findMedian() {
        return lo.size() > hi.size() ? lo.peek() : (lo.peek() + hi.peek()) / 2.0;
    }
}
دو نیمه‌ی یک ترازو

میانه یعنی «مرزِ وسطِ داده‌ها». دو هیپ مثل دو کفهٔ یک ترازو هستند: lo نیمهٔ کوچک‌تر را نگه می‌دارد (با بزرگ‌ترینش رو به بالا)، hi نیمهٔ بزرگ‌تر را (با کوچک‌ترینش رو به پایین). سرِ این دو کفه دقیقاً وسطِ داده‌هاست، پس میانه همیشه دمِ دستت است. addNum برابر O(log n) و findMedian برابر O(1). همین الگوی «مرزِ وسط را نگه دار» مسائلِ «میانهٔ پنجرهٔ لغزان» و «IPO / بیشینه‌سازی سرمایه» را هم حل می‌کند.


تله‌ها و اشتباهات رایج

این‌ها همان چاله‌هایی‌اند که آدم‌های باتجربه هم درشان می‌افتند. یک بار با دقت بخوانشان:

  • سرریز عدد صحیح: mid = lo + (hi - lo) / 2، و برای مجموع‌های در حال رشد از long استفاده کن (subarraySum، ساعت‌های Koko). و بدان Math.abs(Integer.MIN_VALUE) منفی است (چون قرینه‌اش در int جا نمی‌شود).
  • خطای یکی‌کم/یکی‌زیاد در پنجره‌ها: طول پنجره right - left + 1 است. قبل از کدنویسی تصمیم بگیر که آیا پنجره‌ات [left, right] بستهٔ دوطرفه است.
  • جهت حلقهٔ کوله‌پشتی: رو به پایین برای ۰/۱، رو به بالا برای نامحدود. برعکس کردنش بی‌سروصدا پاسخ غلط می‌دهد، نه crash.
  • backtracking بدون کپی عمیق: res.add(path) یک ارجاع ذخیره می‌کند؛ باید res.add(new ArrayList<>(path)) بنویسی وگرنه همهٔ درایه‌ها به‌هم تغییر می‌کنند.
  • پنجرهٔ لغزان با اعداد منفی: برای «مجموع برابر K» کار نمی‌کند — پنجره یکنوا نیست. از جمع پیشوندی + hashmap استفاده کن.
  • حریص بدون اثبات: بسیاری از مسائلِ «به‌ظاهر حریص» (خردکردن سکه با مخرج‌های دلخواه) در واقع DP هستند. اگر بتوانی برای انتخاب حریص یک مثال نقض بسازی، DP است.
  • سرریز مقایسه‌گر PriorityQueue: (a, b) -> a - b برای اعداد بزرگ سرریز می‌کند؛ از Integer.compare(a, b) استفاده کن.
  • فاز دوم فلوید: یک اشاره‌گر را به head بازنشانی کن، نه به همسایهٔ نقطهٔ برخورد.

بهترین شیوه‌ها برای خودِ مصاحبه

مصاحبه فقط کد نیست؛ روایتِ فکرِ توست. این پنج قدم را مثل یک مناسک اجرا کن:

۱. مسئله را بازگو کن و قیدها را تأیید کن (اندازهٔ n، بازهٔ مقادیر، مرتب؟، تکراری؟، منفی؟). قیدها پیچیدگی هدف را به تو می‌گویند: n ≤ 20 فریاد می‌زند backtracking/bitmask؛ n ≤ 10⁵ یعنی O(n log n)؛ n ≤ 10⁹ یعنی جست‌وجوی دودویی روی پاسخ یا ریاضی. ۲. اول brute force و پیچیدگی‌اش را بگو. یک مبنای درستی است و برایت وقت فکر کردن می‌خرد. ۳. نام الگو را بلند بگو. «این یک مسئلهٔ پنجرهٔ لغزان است چون طولانی‌ترین پنجرهٔ پیوسته تحت یک قید را می‌خواهیم.» ۴. قالبی را که حفظ کرده‌ای کد کن؛ حین کدنویسی ناوردا‌ها را روایت کن. ۵. یک مثال کوچک و یک حالت مرزی را دستی اجرا کن (خالی، تک‌عنصری، همه‌یکسان).


سؤالات مصاحبه

حالا رسیدیم به بخشِ طلایی: پانزده سؤالی که در مصاحبه‌های واقعی زیاد پرسیده می‌شوند. هر کدام را در یک جعبهٔ جداگانه گذاشته‌ایم؛ اول خودت سعی کن جواب بدهی، بعد باز کن.

س۱. چه زمانی پنجرهٔ لغزان شکست می‌خورد و باید به جمع پیشوندی سوئیچ کنی؟

وقتی آرایه شامل اعداد منفی است (یا شرطِ «جمع‌کردن پنجره» یکنوا نیست). پنجرهٔ لغزان بر این ناوردا تکیه دارد که بزرگ کردن پنجره به‌طور یکنوا کمیت موردنظر را افزایش (یا کاهش) می‌دهد؛ اعداد منفی این را می‌شکنند. جمع پیشوندی + hashmap زیرآرایه‌ها را صرف‌نظر از علامت در O(n) می‌شمارد.

س۲. جهت حلقهٔ پایین در برابر بالا در کوله‌پشتی را توضیح بده. (سنیور)

در کوله‌پشتی ۰/۱ انتقال یک‌بعدی dp[w] = max(dp[w], dp[w - wt] + val) است. پیمایش w رو به پایین تضمین می‌کند dp[w - wt] هنوز به سطرِ قلمِ قبلی اشاره دارد (قلم حداکثر یک بار). پیمایش رو به بالا اجازه می‌دهد dp[w - wt] قلم جاری را از پیش شامل شود، که دقیقاً معنای نامحدود (اجازهٔ استفادهٔ مجدد) است که خردکردن سکه از آن استفاده می‌کند. همان آرایه، جهت مخالف، مسئلهٔ متفاوت.

س۳. چرا برای زمان‌بندی بازه‌ها را بر اساس زمان پایان مرتب می‌کنیم اما برای ادغام بر اساس زمان شروع؟

ادغام می‌پرسد «کدام بازه‌ها به هم می‌رسند» — ترتیب شروع اجازه می‌دهد از چپ به راست جاروب کنی و گسترش دهی. زمان‌بندی بازه (بیشینهٔ ناهم‌پوشان / کمترین حذف) یک استدلال تبادلی است: بازه‌ای که زودتر تمام می‌شود بیشترین جا را برای بقیه باقی می‌گذارد، پس نگه‌داشتن حریصانهٔ زودترین‌پایان به‌طور اثبات‌پذیر بهینه است. هدف متفاوت، کلید مرتب‌سازی متفاوت.

س۴. گام یافتن نقطهٔ ورودِ فلوید را اثبات کن. (سخت)

فرض کن دُم (head→ورود) طول a دارد، طول حلقه c، و نقطهٔ برخورد slow/fast در b گام داخل حلقه است. fast دو برابر مسافت slow را طی می‌کند: 2(a+b) = a + b + kca + b = kca = kc - b. پس با پیمودن a گام از head، و a گام از نقطهٔ برخورد (که b داخل حلقه است و kc - b بیشتر به ورود می‌رسد)، هر دو روی نقطهٔ ورود می‌نشینند. بنابراین دو اشاره‌گر تک‌گامی آنجا هم‌گرا می‌شوند.

س۵. این چه چاپ می‌کند؟ (باگ را پیدا کن)
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
path.add(1); res.add(path);
path.add(2); res.add(path);
System.out.println(res);

چاپ می‌کند [[1, 2], [1, 2]]، نه [[1], [1, 2]]. هر دو درایه یک ارجاع به یک لیست‌اند. این باگ کلاسیک backtracking است — باید در هر نقطهٔ ثبت یک کپی (new ArrayList<>(path)) اضافه کنی.

س۶. پیچیدگی تولید همهٔ زیرمجموعه‌ها و همهٔ جایگشت‌ها را بده، و چرا.

زیرمجموعه‌ها: O(n · 2ⁿ)2ⁿ زیرمجموعه هست و کپی هرکدام تا O(n) هزینه دارد. جایگشت‌ها: O(n · n!). تعداد برگ‌های درخت بازگشت برابر اندازهٔ خروجی است؛ نمی‌توانی از پیچیدگیِ وابسته‌به‌خروجی بهتر شوی.

س۷. «جست‌وجوی دودویی روی پاسخ» — چطور تشخیصش می‌دهی و چه چیزی باید برقرار باشد؟ (سنیور)

وقتی خواسته «کمینه‌سازی بیشینه» / «بیشینه‌سازی کمینه» / «کوچک‌ترین ظرفیت/سرعت/اندازه‌ای که یک بررسی شدنی‌بودن را رد کند» است. شرط: یک گزارهٔ یکنوای feasible(x) — اگر x جواب دهد، هر x بزرگ‌تر (یا کوچک‌تر) هم جواب دهد. سپس مرز را دودویی جست‌وجو کن. کوکو و موز، حمل بسته در D روز، تقسیم آرایه با بیشینه مجموع، و گاوهای پرخاشگر همگی این اسکلت را دارند.

س۸. چرا `mid = lo + (hi - lo) / 2` به‌جای `(lo + hi) / 2`؟

lo + hi وقتی هر دو نزدیک Integer.MAX_VALUE باشند می‌تواند int را سرریز کند و اندیس منفی و ArrayIndexOutOfBoundsException بدهد. شکل تفریقی برای lo ≤ hi نامنفی هرگز سرریز نمی‌کند. خودِ Arrays.binarySearch جاوا در تاریخ همین باگ را داشت.

س۹. K برتر: min-heap اندازهٔ K در برابر max-heap اندازهٔ N در برابر quickselect — مصالحه‌ها؟

min-heap اندازهٔ K: زمان O(n log k)، حافظه O(k)، روی جریان کار می‌کند، ترتیب را نگه می‌دارد — بهترین پیش‌فرض. max-heap اندازهٔ N: با heapify زمان O(n + k log n)، اما حافظهٔ O(n). quickselect: میانگین O(n)، درجا، اما بدترین حالت O(n²) (با pivot تصادفی کاهش می‌یابد) و بدون مرتب‌سازی اضافه، K را مرتب نمی‌دهد. مصاحبه‌گر می‌خواهد min-heap اندازهٔ K را توجیه کنی.

س۱۰. این چه چاپ می‌کند؟ (باگ را پیدا کن)
PriorityQueue<Integer> pq = new PriorityQueue<>((a, b) -> b - a);
pq.offer(2_000_000_000); pq.offer(-2_000_000_000);
System.out.println(pq.poll());

ممکن است -2000000000 چاپ کند (برای max-heap غلط است). b - a سرریز می‌کند: (-2e9) - (2e9) به یک int مثبت می‌پیچد و مقایسه را وارونه می‌کند. درست: (a, b) -> Integer.compare(b, a).

س۱۱. LIS در `O(n log n)` — آیا آرایهٔ `tails` برابر خودِ زیردنباله است؟ (تله)

نه. tails[i] کوچک‌ترین دُم ممکن هر زیردنبالهٔ صعودی به طول i+1 است؛ محتوای آرایه عموماً یک زیردنبالهٔ معتبر از ورودی نیست. فقط طول درست را تضمین می‌کند. بازسازی خودِ LIS نیازمند یک آرایهٔ اشاره‌گر-والد است که حین به‌روزرسانی‌های جست‌وجوی دودویی ثبت شود.

س۱۲. مسئله‌ای بده که حریص درست به نظر برسد اما غلط باشد، و راه‌حلش.

خردکردن سکه با مخرج‌های [1, 3, 4] و مقدار 6. حریص (بزرگ‌ترین سکه اول) 4+1+1 = 3 سکه می‌دهد؛ بهینه 3+3 = 2 سکه است. حریص شکست می‌خورد چون یک سکهٔ محلاً بزرگ‌تر می‌تواند جفت‌شدن سراسری بهتر را مسدود کند. راه‌حل: DP (dp[a] = min روی سکه‌ها از dp[a-coin]+1). درس: حریص نیازمند اثبات استدلال‌تبادلی است؛ در نبودش، DP.

س۱۳. در «کوچک‌ترین پنجرهٔ پوشاننده»، آرایهٔ `need` چطور کاراکترهای موردنیاز را از مازاد تشخیص می‌دهد؟ (سنیور)

need[c] با تعداد موردنیاز از t شروع می‌شود. هر حرکتِ راست need[c]-- می‌کند؛ required فقط وقتی کم می‌شود که مقدار پیش از کاهش > 0 باشد (کاراکتری واقعاً موردنیاز). کاراکترهای مازاد need[c] را منفی می‌کنند. در جمع‌کردنِ چپ، need[c]++؛ required فقط وقتی زیاد می‌شود که تعداد یک کاراکتر موردنیاز را دوباره به بالای صفر برسانیم (پیش از افزایش == 0). پس علامت این را حمل می‌کند که «آیا تقاضای این کاراکتر را برآورده کرده‌ام»، و بررسی اعتبار O(1) می‌شود.

س۱۴. بیشینهٔ پنجرهٔ لغزان در `O(n)` — کدام ساختار و چرا نه هیپ؟

یک deque یکنوا که اندیس‌ها را به ترتیب مقدار نزولی نگه می‌دارد. جلوی deque همیشه بیشینهٔ پنجره است؛ عقب را تا وقتی ≤ عنصر ورودی است pop می‌کنی، و جلو را وقتی از پنجره خارج می‌شود pop می‌کنی. مجموعاً O(n). max-heap زمان O(n log k) می‌دهد و به حذف تنبل درایه‌های خارج‌ازپنجره نیاز دارد، پس اکیداً بدتر است — deque پاسخ موردانتظار است.

س۱۵. قیدها به‌تنهایی چطور الگوریتم موردنظر را به تو می‌گویند؟ (سنیور)

n را به بودجهٔ پیچیدگی نگاشت کن (~۱۰⁸ عملیات بر ثانیه): n ≤ 12O(n!) backtracking/bitmask DP؛ n ≤ 20–25O(2ⁿ) DP زیرمجموعه؛ n ≤ 500O(n³)؛ n ≤ 5000O(n²)؛ n ≤ 10⁵–10⁶O(n log n) یا O(n)؛ n ≤ 10⁹ یا بازهٔ مقدار عظیم → جست‌وجوی دودویی روی پاسخ، ریاضی، یا توان‌رسانی ماتریسی. خواندن قیدها اول، اغلب الگو را قبل از هضم کامل مسئله آشکار می‌کند.


جمع‌بندی

مصاحبهٔ کدنویسی «اختراع» نیست، تشخیص الگو است: در ۹۰ ثانیهٔ اول، از روی شکل ورودی و صورت خواسته، مسئله را روی یکی از این الگوها نگاشت کن. دو اشاره‌گر برای آرایهٔ مرتب و زوج/سه‌تایی؛ پنجرهٔ لغزان برای زیررشتهٔ پیوسته با قید (فقط وقتی کمیت یکنوا باشد)؛ تند/کند برای حلقه و وسطِ لیست پیوندی بدون حافظهٔ اضافه؛ جمع پیشوندی برای مجموعِ بازه و زیرآرایه با اعداد منفی؛ جست‌وجوی دودویی روی آرایهٔ مرتب یا روی خودِ فضای پاسخ وقتی گزاره‌ای یکنوا داری؛ backtracking برای برشماریِ زیرمجموعه/جایگشت با «انتخاب-کاوش-باطل‌کن»؛ حریصانه فقط با اثباتِ استدلال‌تبادلی؛ DP وقتی زیرمسئله‌های هم‌پوشان و زیرساختار بهینه داری (و مراقبِ جهتِ حلقهٔ کوله‌پشتی باش)؛ بازه‌ها با «مرتب کن، جاروب کن»؛ پشتهٔ یکنوا برای «عنصر بزرگ‌تر/کوچک‌تر بعدی» و هیستوگرام؛ و هیپ/quickselect برای «K تای برتر» و میانهٔ جریانی. تله‌های همیشگی را حفظ کن: سرریزِ mid، جهتِ کوله‌پشتی، کپیِ عمیق در backtracking، و سرریزِ مقایسه‌گر. و یادت باشد: اول قیدها را بخوان — اغلب همان‌ها الگو را لو می‌دهند.

Picture yourself as a seasoned chef, five years in the kitchen. Nobody expects you to invent a brand-new recipe under pressure. They expect you to glance at the ingredients on the counter and instantly know: this becomes a soup, that becomes a stew — reach for the technique you've drilled a thousand times. An algorithm interview is exactly this. The goal of this chapter is to turn you from "someone who solves every problem from scratch" into "someone who recognizes the shape of a problem in seconds."

Your roadmap for this chapter

Here's the path we'll walk together: first, how to spot the pattern from the input shape and the ask. Then we dissect the 11 core patterns one by one — two pointers, sliding window, fast/slow pointers, prefix sums, binary search (even on the answer space), backtracking, greedy, dynamic programming, intervals, monotonic stack, and heaps. For each we give an analogy, one or two solved problems with compilable Java, and the complexity you must state cold. We close with a full "common pitfalls" section, the "etiquette of the interview itself," and 15 real interview questions with complete answers.

Part 0 — a few words you must know cold

Before we start, let's build four words so we never drop them on you unexplained later.

  • Complexity O(n) (big-O): how an algorithm's work grows as the input grows. Imagine hunting for a name in an n-page phone book. Page by page is O(n); halving the search each time (binary search) is O(log n); scanning the whole array once per element is O(n²).
  • Brute force: the simplest solution that comes to mind, even if slow — e.g. trying all pairs. Always state it first; it's both a correctness baseline and a way to buy thinking time.
  • Invariant: a statement that stays true through the entire run of a loop, like a house rule that's never broken. In a sorted array, "every pair we skipped was provably not a solution" is an invariant.
  • Monotonic: something that moves in only one direction — always growing or always shrinking, never bouncing. Like water heating on a stove, only rising. You'll see "monotonic" a lot, because it's the heart of many of these patterns.
Why these four?

Almost every algorithmic decision in this chapter reduces to one of them: "Is this quantity monotonic?" decides whether a sliding window works. "What's the invariant?" decides why skipping a pair is safe. And always start with brute force so you never freeze at zero.

Mental model: interviews are pattern recognition, not invention

At 5 years in, you are not expected to derive Dijkstra from scratch under pressure. You are expected to map a problem onto a known pattern in the first 90 seconds, state the brute force, then reach for the pattern that removes a factor of n (or turns exponential into polynomial).

The golden insight: the recognition signal is almost always hidden in the input shape and the ask. Like a doctor guessing a diagnosis from a handful of symptoms, you guess the pattern from a few keywords. This table is your diagnostic chart:

Cue in the problem Reach for
Sorted array / pair summing to target Two pointers
"Contiguous subarray/substring", longest/shortest window with a constraint Sliding window
Linked list cycle, find middle, "no extra space" Fast/slow pointers
Many range-sum queries, subarray sums Prefix sums
Sorted, "find X", or minimize the max / maximize the min Binary search (incl. on answer space)
"All combinations/permutations/subsets", constraint satisfaction Backtracking
Local optimal → global optimal, scheduling Greedy
"Count ways", "min/max cost", overlapping subproblems Dynamic programming
Meetings, merge ranges, overlaps Intervals (sort + sweep)
"Next greater/smaller element", histogram Monotonic stack
"Top K", "K closest", streaming median Heap / quickselect
The table above is a subway map

A first-time visitor opens the subway map, finds their origin and destination, and instantly knows which line to board. You don't rediscover the city each time either; you locate just two things — the "input shape" and "what they're asking" — and the right line reveals itself. Stare at this table until it's as familiar as the transit map of your own city.

The rest of this chapter drills each pattern: the recognition cue, one or two solved problems with idiomatic Java, and the complexity you must be able to state cold.


1. Two pointers

Two people closing in from both ends of a line

Imagine people standing in a line sorted by height, and you're looking for two whose heights sum to exactly 340 cm. You don't try all pairs. Put one finger on the shortest end and one on the tallest. If the sum is too small, move the short-side finger up (heights grow); if too big, pull the tall-side finger back. Because the line is sorted, you always know exactly which side to move.

Recognize: a sorted array (or one you can sort), and you need a pair/triple, or you're partitioning in place. Two indices walking toward each other (or same-direction, one lagging) turn an O(n²) scan into O(n). The keyword is "sorted" — sortedness is what lets you throw away half the pairs.

Two Sum II (sorted input)

// Sorted ascending. Return 1-based indices of the pair summing to target.
int[] twoSumSorted(int[] a, int target) {
    int lo = 0, hi = a.length - 1;
    while (lo < hi) {
        int sum = a[lo] + a[hi];
        if (sum == target) return new int[]{lo + 1, hi + 1};
        if (sum < target) lo++;   // need bigger -> move left pointer up
        else hi--;                // need smaller -> move right pointer down
    }
    return new int[]{-1, -1};
}

Read it line by line. lo is the finger on the short side, hi on the tall side. While they haven't met (lo < hi), compute the sum. Too small → lo++ ("go taller"); too big → hi-- ("go shorter"). O(n) time, O(1) space.

The invariant that makes this safe

Every pair we skip is provably not a solution because the array is sorted. When sum < target and we advance lo, we're saying "all pairs matching a[lo] with anything smaller than a[hi] were even smaller, so they're useless." That "proof of skipping" is what turns O(n²) into O(n) — not a trick, an argument.

3Sum (the classic dedup trap)

Now the ask is a triple summing to zero. The trick: fix one element as the "anchor" and run a Two-Sum two-pointer over the rest to find its complement.

List<List<Integer>> threeSum(int[] nums) {
    Arrays.sort(nums);
    List<List<Integer>> res = new ArrayList<>();
    for (int i = 0; i < nums.length - 2; i++) {
        if (i > 0 && nums[i] == nums[i - 1]) continue;   // skip duplicate anchors
        if (nums[i] > 0) break;                          // no way to reach 0 with all positives
        int lo = i + 1, hi = nums.length - 1;
        while (lo < hi) {
            int sum = nums[i] + nums[lo] + nums[hi];
            if (sum < 0) lo++;
            else if (sum > 0) hi--;
            else {
                res.add(List.of(nums[i], nums[lo], nums[hi]));
                while (lo < hi && nums[lo] == nums[lo + 1]) lo++;  // skip dup lows
                while (lo < hi && nums[hi] == nums[hi - 1]) hi--;  // skip dup highs
                lo++; hi--;
            }
        }
    }
    return res;
}

O(n²) time (sort is O(n log n), dominated by the nested walk), O(1) extra beyond output.

The gotcha that fails interviews

If you skip only duplicate anchors but forget to skip duplicate lo/hi after finding a hit, you emit duplicate triples and fail. You must skip duplicates at all three positions (anchor, low, high). The line if (nums[i] > 0) break; is a subtle prune: once the anchor itself is positive, everything after it is too (sorted), so three positives can never sum to zero.


2. Sliding window

A sliding window on a moving train

Stand beside a moving train and watch cars through a sliding sash. Push the right edge forward to let a new car enter the frame; whenever the frame gets "crowded" (a constraint breaks), slide the left edge forward so the front cars exit. Each car enters exactly once and exits exactly once — which is why the whole thing stays O(n), not O(n²).

Recognize: "contiguous" subarray/substring, and you want the longest/shortest/all windows satisfying a constraint. A window [left, right] expands on the right and contracts on the left; each index enters and leaves at most once → O(n). The magic word is "contiguous."

Longest substring without repeating characters

int lengthOfLongestSubstring(String s) {
    int[] last = new int[128];        // last seen index+1 per ASCII char, 0 = unseen
    int left = 0, best = 0;
    for (int right = 0; right < s.length(); right++) {
        char c = s.charAt(right);
        left = Math.max(left, last[c]);   // jump left past the previous occurrence
        best = Math.max(best, right - left + 1);
        last[c] = right + 1;              // store index+1 so 0 means "unseen"
    }
    return best;
}

Here right is the window's leading edge, swallowing one fresh char each step. If we've seen that char before, we jump left to sit just past its previous occurrence — shrinking from the left until the window is "repeat-free" again. The index+1 trick is clever: since last starts at zero, storing the real index would confuse the char at position 0 with "unseen"; storing index+1 makes zero unambiguously mean "never seen."

Minimum window substring (variable window with a need-map)

String minWindow(String s, String t) {
    int[] need = new int[128];
    for (char c : t.toCharArray()) need[c]++;
    int required = t.length();                 // total chars still to satisfy
    int left = 0, bestLen = Integer.MAX_VALUE, bestL = 0;
    for (int right = 0; right < s.length(); right++) {
        if (need[s.charAt(right)]-- > 0) required--;   // consumed a needed char
        while (required == 0) {                        // window valid -> try shrinking
            if (right - left + 1 < bestLen) { bestLen = right - left + 1; bestL = left; }
            if (need[s.charAt(left)]++ == 0) required++; // releasing a needed char
            left++;
        }
    }
    return bestLen == Integer.MAX_VALUE ? "" : s.substring(bestL, bestL + bestLen);
}

O(|s| + |t|). Here need[c] is a "shopping list": how many of each char you still need. Each char you swallow crosses one off; when required hits zero, every item is satisfied and the window is "valid," so you start shrinking from the left to find the smallest valid window.

Why do we let need go negative?

The elegance is that need goes negative for surplus chars. If you have three as but needed only one, need['a'] sinks to -2. Now the > 0 / == 0 checks distinguish "needed" from "extra" for free: only when the pre-decrement value was > 0 did you consume a genuinely required char. The sign itself carries the story of "have I met the demand for this char."

Fixed-size window (e.g. max sum of k consecutive elements) is the simpler cousin: add the incoming, subtract the outgoing, no inner while loop. Because the length is fixed, you never need a variable shrink.


3. Fast & slow pointers (Floyd)

Two runners on a circular track

Put two runners on a track; one takes one step per second (slow), the other two (fast). If the track is a straight, endless road, the fast one pulls ahead forever and never meets the slow one. But if the track has a loop, the fast one — like running around a stadium — eventually laps the slow one from behind, and they collide. That "collide or not" tells you whether a cycle exists — with no extra memory.

Recognize: linked lists — cycle detection, find the middle, find the cycle entry — or "detect a repeat in a functional sequence" (LeetCode 287) with O(1) space. Whenever you hear "no extra space" and "linked list," your mind should jump to Floyd.

Cycle detection + entry point

ListNode detectCycle(ListNode head) {
    ListNode slow = head, fast = head;
    while (fast != null && fast.next != null) {
        slow = slow.next;            // +1 per step
        fast = fast.next.next;       // +2 per step
        if (slow == fast) {          // they meet -> cycle exists
            ListNode p = head;
            while (p != slow) { p = p.next; slow = slow.next; }  // Floyd phase 2
            return p;                // cycle entry
        }
    }
    return null;                     // fast hit null -> no cycle
}

O(n) time, O(1) space. Phase one detects the collision. Phase two is a beautiful bit of math magic: after they meet, the distance from head to the entry equals the distance from the meeting point to the entry. So you reset one pointer to head and advance both one step at a time; they converge exactly at the cycle's entry. (Full proof in interview Q4.)

Find the middle

ListNode middle(ListNode head) {
    ListNode slow = head, fast = head;
    while (fast != null && fast.next != null) { slow = slow.next; fast = fast.next.next; }
    return slow;   // for even length, returns the second middle
}

Same idea, simpler: when the fast runner reaches the end, the slow one is exactly at the middle — having covered half the distance. This "find the middle in one pass" trick underpins linked-list merge sort and much more.


4. Prefix sums (and difference arrays)

The car's odometer

Say you want to know how far you drove between the 120 km marker and the 350 km marker. You don't redrive and re-measure; you just take the odometer reading at 350 minus the reading at 120. The odometer is a "cumulative sum" you stored ahead of time, so any range answer is a single subtraction — O(1).

Recognize: many "sum of range [i, j]" queries, or "count subarrays with sum k". Precompute cumulative sums so each range answer is O(1).

Subarray sum equals K

int subarraySum(int[] nums, int k) {
    Map<Integer, Integer> count = new HashMap<>();
    count.put(0, 1);                 // empty prefix sums to 0, occurs once
    int prefix = 0, result = 0;
    for (int n : nums) {
        prefix += n;
        // number of earlier prefixes P such that prefix - P == k
        result += count.getOrDefault(prefix - k, 0);
        count.merge(prefix, 1, Integer::sum);
    }
    return result;
}

See the logic in odometer terms: if "cumulative sum so far" is prefix, then any subarray summing to k started at a point whose cumulative sum was prefix - k. So we just count how many times we've previously reached cumulative value prefix - k. O(n) time and space.

Why sliding window fails here

Sliding window assumes growing the window increases the sum monotonically. But with negatives, adding an element can decrease the sum, so it's no longer monotonic and the window breaks. Prefix sums count prefix frequencies (not sum monotonicity), so they work regardless of sign. Interviewers love probing this distinction.

2D prefix sums answer submatrix-sum queries in O(1) after O(mn) preprocessing: sum(r1,c1,r2,c2) = P[r2+1][c2+1] - P[r1][c2+1] - P[r2+1][c1] + P[r1][c1]. (Same "odometer subtraction," now in two dimensions via inclusion-exclusion.)

Difference array is the inverse of prefix sums: to apply many range-increment updates then read once, add +delta at l and -delta at r+1, then take a prefix sum. Each update is O(1). Like marking only "where an increment starts and stops," then summing it all up at the end.


5. Binary search — including on the answer space

The number-guessing game

Someone picks a number between 1 and 1000; you guess, and after each guess they only say "higher" or "lower." You name the midpoint of the remaining range each time, killing half the numbers. Done in ten guesses. Now the senior twist: you don't strictly need a "sorted array." You just need a boundary — false up to some point, then true forever after. You binary-search that boundary.

Recognize: monotonic search space. Either a sorted array, or a predicate f(x) that is false-false-…-true-true (a boundary). A predicate is just a function that returns yes/no. The second form — "binary search on the answer" — is the senior differentiator.

The lower-bound template (memorize this one)

// First index in [lo, hi) where pred(index) is true; hi if none.
int lowerBound(int lo, int hi, IntPredicate pred) {
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;   // avoids int overflow vs (lo+hi)/2
        if (pred.test(mid)) hi = mid;   // answer is mid or left
        else lo = mid + 1;              // answer is strictly right
    }
    return lo;
}

This template finds "the first place the predicate turns true" — the false→true boundary. If pred(mid) is true, maybe mid itself is the answer, so we don't discard it (hi = mid); if false, the answer is strictly to the right (lo = mid + 1).

Koko eating bananas (search on answer)

Now the real magic: the problem has no sorted array at all. Koko has n piles and must finish within h hours; we want the minimum eating speed (bananas per hour). "The smallest speed that finishes in time" means searching on the answer itself.

// Minimum eating speed to finish all piles within h hours.
int minEatingSpeed(int[] piles, int h) {
    int lo = 1, hi = Arrays.stream(piles).max().getAsInt();
    while (lo < hi) {
        int speed = lo + (hi - lo) / 2;
        long hours = 0;
        for (int p : piles) hours += (p + speed - 1) / speed;  // ceil(p/speed)
        if (hours <= h) hi = speed;    // feasible -> try slower
        else lo = speed + 1;           // too slow -> need faster
    }
    return lo;
}

O(n log maxPile). The pattern: define feasible(x) (monotonic in x — the faster you eat, the sooner you finish), binary-search the smallest/largest feasible x. Same skeleton solves "split array largest sum", "capacity to ship packages in D days", "minimize max distance".

Overflow gotcha and the binarySearch encoding

Always mid = lo + (hi - lo) / 2, never (lo + hi) / 2. If lo and hi are both near Integer.MAX_VALUE, lo + hi overflows int, giving a negative value and an ArrayIndexOutOfBoundsException. The subtraction form never overflows. And in Java, Arrays.binarySearch returns -(insertionPoint) - 1 on a miss — know the encoding; you'll need it in LIS.


6. Backtracking

Exploring a maze with Ariadne's thread

In a maze, at each fork you pick a path and unspool a thread behind you. Hit a dead end? Follow the thread back exactly to the last fork and try the other path. Backtracking is precisely this: add a choice, explore forward, and when a branch is exhausted, "undo" the last choice and step back. That "undo" is reeling in the thread.

Recognize: enumerate all subsets/permutations/combinations, or constraint satisfaction (N-Queens, Sudoku, word search). Build a candidate incrementally, and undo the last choice when a branch is exhausted. Complexity is output-bound: subsets O(2ⁿ), permutations O(n!). That is, the number of answers itself is this big, and you can't beat it.

Subsets

List<List<Integer>> subsets(int[] nums) {
    List<List<Integer>> res = new ArrayList<>();
    backtrack(nums, 0, new ArrayList<>(), res);
    return res;
}
void backtrack(int[] nums, int start, List<Integer> path, List<List<Integer>> res) {
    res.add(new ArrayList<>(path));          // every node is a valid subset; copy it
    for (int i = start; i < nums.length; i++) {
        path.add(nums[i]);                    // choose
        backtrack(nums, i + 1, path, res);    // explore (i+1 avoids reusing/reordering)
        path.remove(path.size() - 1);         // un-choose (the backtrack step)
    }
}

The three lines inside the loop are exactly the maze triad: choose (path.add), explore (recurse), un-choose (path.remove). The start parameter keeps us moving forward so each subset is built once, not in different orders.

Permutations

List<List<Integer>> permute(int[] nums) {
    List<List<Integer>> res = new ArrayList<>();
    boolean[] used = new boolean[nums.length];
    dfs(nums, used, new ArrayList<>(), res);
    return res;
}
void dfs(int[] nums, boolean[] used, List<Integer> path, List<List<Integer>> res) {
    if (path.size() == nums.length) { res.add(new ArrayList<>(path)); return; }
    for (int i = 0; i < nums.length; i++) {
        if (used[i]) continue;
        used[i] = true; path.add(nums[i]);
        dfs(nums, used, path, res);
        used[i] = false; path.remove(path.size() - 1);   // undo both mutations
    }
}

The difference from subsets is that order matters and each element must appear once per permutation. The used array is a "reserved" sign: it marks which elements are currently present in the path under construction. Note that when undoing you must revert both mutations (used[i] and path).

N-Queens (column + two-diagonal marking)

int totalNQueens(int n) {
    return place(n, 0, new boolean[n], new boolean[2*n], new boolean[2*n]);
}
// col[c], diag[r+c], anti[r-c+n] track occupied lines.
int place(int n, int row, boolean[] col, boolean[] diag, boolean[] anti) {
    if (row == n) return 1;
    int count = 0;
    for (int c = 0; c < n; c++) {
        int d = row + c, a = row - c + n;
        if (col[c] || diag[d] || anti[a]) continue;      // attacked -> prune
        col[c] = diag[d] = anti[a] = true;
        count += place(n, row + 1, col, diag, anti);
        col[c] = diag[d] = anti[a] = false;              // backtrack
    }
    return count;
}

The r+c / r-c+n trick maps each diagonal to a unique index so the attack check is O(1). Why? Every cell on one main diagonal shares the same row+col, and every cell on one anti-diagonal shares the same difference (which we shift by +n to make it a valid array index). So instead of scanning the diagonal, you check a single boolean.

Dedup when the input has duplicates

For problems like subsetsWithDup or permuteUnique, sort first, then add if (i > start && nums[i] == nums[i-1]) continue;. This means "at this tree level, I won't retry a value I just tried." Note the condition is i > start, not i > 0 — at a deeper level (the next recursion) you're allowed to reuse the same value; only sibling repeats at the same level are banned.


7. Greedy

Packing a hiking backpack

You're packing for a hike, each time grabbing whatever "seems best" and stuffing it in. Sometimes this works and you pack the lightest possible bag; sometimes an early choice (a big can of food) crowds out two smaller, more useful items. The difference between those cases you learn only by proof, not by feel. Greedy means "always the best local choice," but it's only allowed when you can prove it never leads you into a corner.

Recognize: a locally optimal choice provably leads to a global optimum. The hard part is proving the greedy invariant (exchange argument) — if you can't, it's probably DP.

Jump Game (reachability)

boolean canJump(int[] nums) {
    int farthest = 0;
    for (int i = 0; i < nums.length; i++) {
        if (i > farthest) return false;             // can't even reach i
        farthest = Math.max(farthest, i + nums[i]);  // greedily extend reach
    }
    return true;
}

Here being greedy means: at each cell we keep only "the farthest I can reach so far." If the counter i overtakes that reach, there's an unbridgeable gap and we can never get there.

Non-overlapping intervals (interval scheduling — greedy by end time)

// Min intervals to remove so the rest don't overlap.
int eraseOverlapIntervals(int[][] intervals) {
    Arrays.sort(intervals, (x, y) -> Integer.compare(x[1], y[1])); // sort by END
    int end = Integer.MIN_VALUE, removed = 0;
    for (int[] iv : intervals) {
        if (iv[0] >= end) end = iv[1];   // keep it: no overlap with last kept
        else removed++;                  // overlaps -> drop the one ending later
    }
    return removed;
}
Why sort by end time?

This is the classic exchange argument: the interval that finishes earliest leaves the most room for the rest. Imagine you have several meetings and want to hold the maximum number of non-overlapping ones; always keep the one that ends soonest, because it frees the room fastest. You can prove keeping earliest-ending is never worse than any other choice. O(n log n).


8. Dynamic programming

Building a Lego tower with saved half-built pieces

Imagine building a huge Lego tower and noticing that many mid-sized chunks recur again and again. The smart builder assembles each half-built chunk once, drops it in a labeled box, and next time reaches into the box instead of rebuilding. Dynamic programming is exactly this: break the problem into subproblems, solve each subproblem once and store its answer (memoization / table), and compose them into the big answer.

Recognize: optimal substructure + overlapping subproblems. That is, the big answer is built from optimal answers to smaller pieces, and those pieces recur. Signals: "count the number of ways", "min/max cost/length", "can you reach/partition". Define three things — the state (what describes a subproblem), the transition (how you get here from smaller subproblems), and the base case — then decide top-down (memoized recursion) vs bottom-up (table).

1D — Climbing stairs / House robber

// House Robber: max sum with no two adjacent picked.
int rob(int[] nums) {
    int take = 0, skip = 0;              // best if we did / didn't rob previous house
    for (int n : nums) {
        int newTake = skip + n;          // rob this -> must have skipped previous
        skip = Math.max(skip, take);     // skip this -> best of previous states
        take = newTake;
    }
    return Math.max(take, skip);
}

O(n) time, O(1) space. Here the whole "DP table" collapses to two rolling variables, because each state depends only on the previous one. take is "best loot if I rob this house," skip is "best loot if I skip it." Since you can't rob two adjacent houses, to rob this one you must have skipped the previous.

0/1 Knapsack (2D → 1D space optimization)

int knapsack(int[] weight, int[] value, int cap) {
    int[] dp = new int[cap + 1];         // dp[w] = best value with capacity w
    for (int i = 0; i < weight.length; i++)
        for (int w = cap; w >= weight[i]; w--)   // iterate capacity DOWNWARD
            dp[w] = Math.max(dp[w], dp[w - weight[i]] + value[i]);
    return dp[cap];
}

O(n·cap) time, O(cap) space.

Critical gotcha: loop direction

The inner loop for 0/1 knapsack must go downward (each item used once). Why? When you go downward, dp[w - weight[i]] still holds the value before adding the current item, so the same item isn't counted twice. For unbounded knapsack / coin change, go upward so an item can be reused within the same pass. The nastiest part of this bug is that it doesn't crash — it silently produces wrong answers.

Coin change (min coins, unbounded)

int coinChange(int[] coins, int amount) {
    int[] dp = new int[amount + 1];
    Arrays.fill(dp, amount + 1);         // sentinel = "infinity"
    dp[0] = 0;
    for (int coin : coins)
        for (int a = coin; a <= amount; a++)     // UPWARD -> coins reusable
            dp[a] = Math.min(dp[a], dp[a - coin] + 1);
    return dp[amount] > amount ? -1 : dp[amount];
}

O(amount·coins). Here amount + 1 plays "infinity": since no valid answer needs more than amount coins, anything larger means "impossible." Note this loop is upward — the exact opposite of 0/1 knapsack — because each coin can be used many times.

Longest Increasing Subsequence (patience sorting, O(n log n))

int lengthOfLIS(int[] nums) {
    int[] tails = new int[nums.length];  // tails[i] = smallest tail of an LIS of length i+1
    int size = 0;
    for (int x : nums) {
        int i = Arrays.binarySearch(tails, 0, size, x);
        if (i < 0) i = -(i + 1);         // decode insertion point
        tails[i] = x;                    // replace first tail >= x (or extend)
        if (i == size) size++;
    }
    return size;                         // NOTE: tails is not the actual LIS, only its length
}

The O(n²) DP (dp[i] = 1 + max dp[j] for j<i, nums[j]<nums[i]) is fine to state first, then optimize to O(n log n) with the tails array + binary search. The name "patience sorting" comes from the card game: you place each card on the first pile whose top card exceeds it. Here we do exactly that with binarySearch; and remember that since the value isn't found, binarySearch returns an encoded negative that we decode with -(i+1).

Edit distance (2D DP)

int minDistance(String a, String b) {
    int m = a.length(), n = b.length();
    int[][] dp = new int[m + 1][n + 1];
    for (int i = 0; i <= m; i++) dp[i][0] = i;   // delete all of a's prefix
    for (int j = 0; j <= n; j++) dp[0][j] = j;   // insert all of b's prefix
    for (int i = 1; i <= m; i++)
        for (int j = 1; j <= n; j++)
            dp[i][j] = a.charAt(i-1) == b.charAt(j-1)
                ? dp[i-1][j-1]                                  // chars match -> free
                : 1 + Math.min(dp[i-1][j-1],                   // replace
                      Math.min(dp[i-1][j], dp[i][j-1]));       // delete / insert
    return dp[m][n];
}

O(mn) time and space; reducible to O(min(m,n)) space by keeping two rows.

Why edit distance is the golden template

Learn the transition on this grid and you get several problems for free: LCS (longest common subsequence), longest common substring, and regex/wildcard matching. They're all one 2D table where each cell asks "what if these two chars matched? if not, which move (insert/delete/replace) is best?". One grid, dozens of problems.


9. Intervals

A meeting-room booking calendar

You have a pile of room-booking requests, each with a "start time" and "end time." The first thing any seasoned secretary does is line them up on the calendar by start time, then scan top to bottom to see which ones collide. That "sort, then sweep" is the soul of every interval problem.

Recognize: the input is a list of [start, end] pairs and you must merge, insert, count overlaps, or find free slots. The universal first move is sort by start (sometimes by end for greedy scheduling), then sweep.

Merge intervals

int[][] merge(int[][] intervals) {
    Arrays.sort(intervals, (a, b) -> Integer.compare(a[0], b[0]));  // by start
    List<int[]> out = new ArrayList<>();
    int[] cur = intervals[0];
    for (int i = 1; i < intervals.length; i++) {
        if (intervals[i][0] <= cur[1])                 // overlap
            cur[1] = Math.max(cur[1], intervals[i][1]); // extend current
        else { out.add(cur); cur = intervals[i]; }      // gap -> flush and start new
    }
    out.add(cur);
    return out.toArray(new int[0][]);
}

Because you sorted by start, you only need to hold the current interval: if the next one starts before the current's end, they overlap and you extend the current's end (to whichever is farther); otherwise there's a gap, so flush the current and start fresh.

Meeting rooms II (min rooms = max concurrent overlaps)

int minMeetingRooms(int[][] meetings) {
    int n = meetings.length;
    int[] starts = new int[n], ends = new int[n];
    for (int i = 0; i < n; i++) { starts[i] = meetings[i][0]; ends[i] = meetings[i][1]; }
    Arrays.sort(starts); Arrays.sort(ends);
    int rooms = 0, maxRooms = 0, e = 0;
    for (int s = 0; s < n; s++) {
        while (e < n && ends[e] <= starts[s]) { rooms--; e++; }  // a meeting freed a room
        rooms++;                                                  // this meeting takes a room
        maxRooms = Math.max(maxRooms, rooms);
    }
    return maxRooms;
}

The lovely idea: the minimum number of rooms needed equals the maximum number of meetings running at the same time. So we build two sorted event streams — starts and ends — and, like watching a door in and a door out, each "start" takes a room and each "end" frees one; the peak of occupied rooms is the answer. This "sweep two sorted event streams" idea (or a min-heap of end times) generalizes to any "max concurrent" question. O(n log n).


10. Monotonic stack

A queue of people each waiting for "someone taller"

Imagine people passing by one after another, each waiting for "the next person taller than me." You keep a queue of waiters whose heights decrease front to back. Each tall newcomer "releases" everyone shorter in the queue (their answer is this newcomer) and then stands at the back. Each person enters the queue exactly once and leaves exactly once.

Recognize: "next greater/smaller element", "how far until a larger value", histogram/rectangle areas, stock spans. Maintain a stack that stays sorted (increasing or decreasing); each element is pushed and popped once → O(n). "Monotonic" just means the stack that preserves its ordering.

Daily temperatures (next greater)

int[] dailyTemperatures(int[] t) {
    int[] answer = new int[t.length];
    Deque<Integer> stack = new ArrayDeque<>();   // indices of a decreasing temp sequence
    for (int i = 0; i < t.length; i++) {
        while (!stack.isEmpty() && t[i] > t[stack.peek()]) {
            int prev = stack.pop();              // today is prev's next warmer day
            answer[prev] = i - prev;
        }
        stack.push(i);
    }
    return answer;   // unresolved indices stay 0
}

The stack holds indices of days still waiting for a "warmer day," with decreasing temperatures. Each warmer day that arrives pops all the cooler waiting days off the stack and records their distances. Those that never get an answer stay zero.

Largest rectangle in histogram

int largestRectangleArea(int[] h) {
    Deque<Integer> stack = new ArrayDeque<>();   // increasing heights (by index)
    int best = 0;
    for (int i = 0; i <= h.length; i++) {
        int cur = (i == h.length) ? 0 : h[i];    // sentinel 0 flushes the stack at the end
        while (!stack.isEmpty() && cur < h[stack.peek()]) {
            int height = h[stack.pop()];
            int leftBoundary = stack.isEmpty() ? -1 : stack.peek();
            int width = i - leftBoundary - 1;    // bars strictly between boundaries
            best = Math.max(best, height * width);
        }
        stack.push(i);
    }
    return best;
}

O(n). The key to understanding it: when we pop a bar, i is its first-smaller-to-the-right and the new stack top is its first-smaller-to-the-left. So the distance between those two boundaries is exactly the maximal width for which that bar is the limiting height — and the area is height × width. That sentinel 0 at the end exists so anything still on the stack gets flushed and counted.


11. Top-K / heaps / quickselect

An always-current top-ten list

Imagine you're judging a contest and want to keep only "the top ten scores so far" beside you. You take a small board with ten slots; each new contestant, if their score beats the weakest on the board, you erase the weakest and write them in. You never hold more than ten names. That's exactly a size-K min-heap: the smallest (weakest) is always right at the top, ready to evict.

Recognize: "K largest/smallest/most-frequent", "K closest to origin", "median of a stream". A heap gives O(n log k); quickselect gives average O(n) for a single K-th query. (A heap is that mound-shaped tree where the smallest or largest element is always at the top.)

Top-K frequent elements (min-heap of size K)

int[] topKFrequent(int[] nums, int k) {
    Map<Integer, Integer> freq = new HashMap<>();
    for (int n : nums) freq.merge(n, 1, Integer::sum);
    // Min-heap keyed by frequency; keep only the K most frequent.
    PriorityQueue<Integer> heap = new PriorityQueue<>((a, b) -> freq.get(a) - freq.get(b));
    for (int key : freq.keySet()) {
        heap.offer(key);
        if (heap.size() > k) heap.poll();   // evict the least frequent so far
    }
    int[] res = new int[k];
    for (int i = k - 1; i >= 0; i--) res[i] = heap.poll();
    return res;
}

O(n log k).

Why a size-K min-heap, not a size-N max-heap?

Here's the subtle point: if you want the K largest, you use a size-K min-heap, not a size-N max-heap. Because that little ten-slot board always holds only K elements, with the weakest (smallest) at the top for instant eviction. This cuts memory from O(n) to O(k) and works on an endless data stream too. (If K is a small constant, bucket sort by frequency even gets this to O(n).)

Median from a data stream (two heaps)

class MedianFinder {
    // lo: max-heap of the smaller half; hi: min-heap of the larger half.
    private final PriorityQueue<Integer> lo = new PriorityQueue<>(Collections.reverseOrder());
    private final PriorityQueue<Integer> hi = new PriorityQueue<>();

    public void addNum(int num) {
        lo.offer(num);
        hi.offer(lo.poll());                 // funnel through to keep ordering correct
        if (hi.size() > lo.size()) lo.offer(hi.poll());  // rebalance: lo >= hi in size
    }
    public double findMedian() {
        return lo.size() > hi.size() ? lo.peek() : (lo.peek() + hi.peek()) / 2.0;
    }
}
The two pans of a scale

The median is "the middle boundary of the data." The two heaps are like the two pans of a scale: lo holds the smaller half (largest on top), hi holds the larger half (smallest on top). The tops of these two pans sit exactly at the middle of the data, so the median is always at your fingertips. addNum is O(log n), findMedian is O(1). This same "keep the middle boundary" pattern also solves "sliding window median" and "IPO / maximize capital."


Common pitfalls & gotchas

These are the potholes even experienced people fall into. Read them once, carefully:

  • Integer overflow: mid = lo + (hi - lo) / 2, and use long for running sums (subarraySum, Koko hours). And know that Math.abs(Integer.MIN_VALUE) is negative (its negation doesn't fit in int).
  • Off-by-one in windows: window length is right - left + 1. Decide before coding whether your window is [left, right] inclusive.
  • Knapsack loop direction: downward for 0/1, upward for unbounded. Getting this backwards silently produces wrong answers, not crashes.
  • Backtracking without deep copy: res.add(path) stores a reference; you must res.add(new ArrayList<>(path)) or every entry mutates to empty.
  • Sliding window with negatives: doesn't work for "sum equals K" — the window isn't monotonic. Use prefix sums + hashmap.
  • Greedy without proof: many "greedy-looking" problems (coin change with arbitrary denominations) are actually DP. If you can construct a counterexample to the greedy choice, it's DP.
  • PriorityQueue comparator overflow: (a, b) -> a - b overflows for large ints; use Integer.compare(a, b).
  • Floyd's second phase: reset one pointer to head, not to the meeting point's neighbor.

Best practices for the interview itself

An interview isn't just code; it's the narration of your thinking. Run these five steps like a ritual:

  1. Restate the problem and confirm constraints (n size, value range, sorted?, duplicates?, negatives?). The constraints tell you the target complexity: n ≤ 20 screams backtracking/bitmask; n ≤ 10⁵ means O(n log n); n ≤ 10⁹ means binary search on the answer or math.
  2. State brute force + its complexity first. It's a correctness baseline and buys thinking time.
  3. Name the pattern out loud. "This is a sliding-window problem because we want the longest contiguous window under a constraint."
  4. Code the template you've memorized; narrate invariants as you go.
  5. Dry-run one small example and one edge case (empty, single element, all-same).

Interview Questions

Now the golden part: fifteen questions asked often in real interviews. Each sits in its own box; try to answer before you read on.

Q1. When does a sliding window fail and you must switch to prefix sums?

When the array contains negatives (or the "shrink" condition isn't monotonic). Sliding window relies on the invariant that growing the window monotonically increases (or decreases) the quantity of interest; negatives break that. Prefix-sum + hashmap counts subarrays regardless of sign in O(n).

Q2. Explain the down-vs-up loop direction in knapsack. (senior)

In 0/1 knapsack the 1D transition is dp[w] = max(dp[w], dp[w - wt] + val). Iterating w downward guarantees dp[w - wt] still refers to the previous item's row (item used at most once). Iterating upward lets dp[w - wt] already include the current item, which is exactly the unbounded (reuse-allowed) semantics used by coin change. Same array, opposite direction, different problem.

Q3. Why sort intervals by end time for scheduling but by start time for merging?

Merging asks "which intervals touch" — start order lets you sweep left to right and extend. Interval scheduling (max non-overlapping / min removals) is an exchange argument: the interval finishing earliest leaves maximum room for the rest, so greedily keeping earliest-ending is provably optimal. Different objective, different sort key.

Q4. Prove Floyd's cycle-entry step. (hard)

Let the tail (head→entry) be length a, cycle length c, and the slow/fast meeting point be b steps into the cycle. Fast travels twice slow's distance: 2(a+b) = a + b + kca + b = kca = kc - b. So walking a steps from head, and a steps from the meeting point (which is b into the cycle, and kc - b more reaches the entry), both land on the entry. Hence two 1-step pointers converge there.

Q5. What does this print? (find the bug)
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
path.add(1); res.add(path);
path.add(2); res.add(path);
System.out.println(res);

Prints [[1, 2], [1, 2]], not [[1], [1, 2]]. Both entries are the same list reference. This is the classic backtracking bug — you must add a copy (new ArrayList<>(path)) at each recording point.

Q6. Give the complexity of generating all subsets and all permutations, and why.

Subsets: O(n · 2ⁿ) — there are 2ⁿ subsets and copying each costs up to O(n). Permutations: O(n · n!). The recursion tree's leaf count is the output size; you cannot beat output-bound complexity.

Q7. "Binary search on the answer" — how do you recognize it, and what must hold? (senior)

Recognize it when the ask is "minimize the maximum" / "maximize the minimum" / "smallest capacity/speed/size such that a feasibility check passes". The requirement: a monotonic predicate feasible(x) — if x works, every larger (or smaller) x also works. Then binary-search the boundary. Koko bananas, ship-packages-in-D-days, split-array-largest-sum, aggressive-cows all share this skeleton.

Q8. Why `mid = lo + (hi - lo) / 2` instead of `(lo + hi) / 2`?

lo + hi can overflow int when both are near Integer.MAX_VALUE, producing a negative index and an ArrayIndexOutOfBoundsException. The subtraction form never overflows for non-negative lo ≤ hi. Java's own Arrays.binarySearch had this exact bug historically.

Q9. Top-K: min-heap of size K vs max-heap of size N vs quickselect — trade-offs?

Min-heap size K: O(n log k) time, O(k) space, works on streams, keeps order — best default. Max-heap size N: O(n + k log n) with heapify, but O(n) space. Quickselect: average O(n), in-place, but worst case O(n²) (mitigated by random pivot) and it doesn't give the K in sorted order without an extra sort. Interviewers want you to justify the size-K min-heap.

Q10. What does this print? (find the bug)
PriorityQueue<Integer> pq = new PriorityQueue<>((a, b) -> b - a);
pq.offer(2_000_000_000); pq.offer(-2_000_000_000);
System.out.println(pq.poll());

It may print -2000000000 (wrong for a max-heap). b - a overflows: (-2e9) - (2e9) wraps to a positive int, inverting the comparison. Correct: (a, b) -> Integer.compare(b, a).

Q11. LIS in `O(n log n)` — does the `tails` array equal the actual subsequence? (gotcha)

No. tails[i] is the smallest possible tail of any increasing subsequence of length i+1; the array's contents are generally not a valid subsequence of the input. It only guarantees the correct length. Reconstructing the actual LIS needs a parent-pointer array recorded during the binary-search updates.

Q12. Give a problem where greedy looks right but is wrong, and the fix.

Coin change with denominations [1, 3, 4], amount 6. Greedy (largest coin first) gives 4+1+1 = 3 coins; the optimum is 3+3 = 2 coins. Greedy fails because a locally larger coin can block a better global pairing. Fix: DP (dp[a] = min over coins of dp[a-coin]+1). The lesson: greedy needs an exchange-argument proof; absent one, use DP.

Q13. In "minimum window substring", how does the `need` array distinguish required from surplus characters? (senior)

need[c] starts as the count required from t. Each right-move does need[c]--; it only decrements required when the pre-decrement value was > 0 (a genuinely needed char). Surplus chars drive need[c] negative. On the left-shrink, need[c]++; required only increases when we push a needed char's count back above zero (pre-increment == 0). So sign carries "have I met the demand for this char", giving O(1) validity checks.

Q14. Sliding-window-maximum in `O(n)` — which structure and why not a heap?

A monotonic deque holding indices in decreasing value order. The front is always the window max; you pop the back while it's ≤ the incoming element, and pop the front when it slides out of the window. O(n) total. A max-heap gives O(n log k) and needs lazy deletion of out-of-window entries, so it's strictly worse — the deque is the expected answer.

Q15. How do constraints alone tell you the intended algorithm? (senior)

Map n to a complexity budget (~10⁸ ops/sec): n ≤ 12O(n!) backtracking/bitmask DP; n ≤ 20–25O(2ⁿ) subset DP; n ≤ 500O(n³); n ≤ 5000O(n²); n ≤ 10⁵–10⁶O(n log n) or O(n); n ≤ 10⁹ or huge value range → binary search on answer, math, or matrix exponentiation. Reading constraints first often reveals the pattern before you've fully digested the problem.


In a nutshell

A coding interview is not invention, it's pattern recognition: in the first 90 seconds, map the problem — from its input shape and ask — onto one of these patterns. Two pointers for a sorted array and a pair/triple; sliding window for a contiguous substring under a constraint (only when the quantity is monotonic); fast/slow for a linked-list cycle or middle with no extra space; prefix sums for range/subarray sums with negatives; binary search on a sorted array or on the answer space itself when you have a monotonic predicate; backtracking for enumerating subsets/permutations with "choose-explore-undo"; greedy only with an exchange-argument proof; DP when you have overlapping subproblems and optimal substructure (mind the knapsack loop direction); intervals with "sort, then sweep"; monotonic stack for "next greater/smaller" and histograms; and heap/quickselect for "top K" and streaming median. Burn in the eternal gotchas: the mid overflow, knapsack direction, deep copy in backtracking, and comparator overflow. And remember: read the constraints first — they often give away the pattern.