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

آرایه، لیست، پشته، صف و هشArrays, Lists, Stacks, Queues & Hashing

در این درس از صفر یاد می‌گیری که همهٔ مجموعه‌های خطی جاوا — آرایه، لیست پیوندی، پشته، صف، بافر حلقوی و جدول هش — فقط دو چیدمان حافظه‌اند به‌اضافهٔ یک سیاست دسترسی، و با پیاده‌سازی دستی و مسائل کلاسیک مصاحبه آن‌ها را عمیق می‌فهمی.This lesson teaches you from scratch that every linear collection in Java — arrays, linked lists, stacks, queues, ring buffers, and hash tables — is just two memory layouts plus an access policy, and cements it with from-scratch implementations and the classic interview problems.


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

بیا با هم قدم‌به‌قدم برویم. هیچ اصطلاحی را بدون توضیح رها نمی‌کنم.

نقشهٔ راه این درس

اینجا این‌ها را یاد می‌گیری:

  1. دو زیربنای فیزیکی — حافظهٔ پیوسته (آرایه) در برابر گره‌های پیوندی (اشاره‌گر)، و مدل هزینهٔ هرکدام.
  2. آرایهٔ پویا — چرا ArrayList جادویی به نظر می‌رسد و چرا add سرشکن O(1) است.
  3. لیست پیوندی — یک‌طرفه و دوطرفه، و هنر معکوس‌سازی و جدا کردن گره.
  4. پشته و صف — چرا ArrayDeque بهتر از Stack و LinkedList است، و بافر حلقوی از صفر.
  5. جدول هش — برخورد، ضریب بار، درخت‌سازی، و یک HashMap دست‌ساز.
  6. مسائل کلاسیک — فلوید، پرانتز معتبر، min-stack و LRU.
  7. پرسش‌های مصاحبه — ۱۵ سؤال واقعی با پاسخ کامل.

بخش ۰ — واژه‌هایی که باید بشناسی

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

کتابخانه در برابر زنجیرهٔ آدم‌ها

دو راه برای نگه‌داری ترتیبیِ چیزها تصور کن. راه اول: یک قفسهٔ بلندِ کتاب با خانه‌های شماره‌دار و کنارِ‌هم. برای رسیدن به کتاب شمارهٔ ۵۰ فقط کافی است مستقیم بروی سراغ خانهٔ ۵۰. اما اگر بخواهی کتابی را وسط قفسه جا بدهی، باید همهٔ کتاب‌های بعدی را یکی‌یکی هُل بدهی. راه دوم: یک زنجیره از آدم‌ها که هرکدام دستِ نفر بعدی را گرفته. برای رسیدن به نفر پنجاهم باید از اول زنجیره یکی‌یکی جلو بروی. اما اگر بخواهی کسی را وسط زنجیره اضافه کنی، فقط دو تا دست را باز و بسته می‌کنی. قفسه = آرایه؛ زنجیره = لیست پیوندی. کل این درس تنشِ بین همین دو است.

  • حافظهٔ پیوسته (contiguous): خانه‌هایی که پشت‌سرِ‌هم و چسبیده در حافظه‌اند، مثل خانه‌های قفسه.
  • اشاره‌گر (pointer / reference): یک آدرس که می‌گوید «شیءِ بعدی آنجاست»، مثل دستی که نفر بعدی را گرفته.
  • cache (حافظهٔ نهان) و prefetch (پیش‌واکشی): CPU وقتی یک خانه از حافظه را می‌خواند، حدس می‌زند احتمالاً خانه‌های کنارش را هم می‌خواهی، پس آن‌ها را از قبل می‌آورد. این «پیش‌واکشی» است. حافظهٔ پیوسته با آن سازگار است؛ پرش‌های تصادفی نه.
  • cache miss: وقتی داده‌ای که می‌خواهی در حافظهٔ نهانِ سریع نیست و باید از RAM کندِ دور بیایی — گران است. تعقیب اشاره‌گر (pointer chasing) پر از cache miss است.
  • GC (زباله‌روب / garbage collector): بخشی از جاوا که حافظهٔ اشیایی را که دیگر هیچ ارجاعی به آن‌ها نیست پس می‌گیرد. اگر یک ارجاع کهنه نگه داری، GC نمی‌تواند آن را آزاد کند.
  • loitering (پرسه‌زنی): یک نوع نشت حافظه که در آن یک شیء منطقاً حذف شده اما یک ارجاع کهنه هنوز به آن چسبیده، پس GC آزادش نمی‌کند.
  • سرشکن (amortized): میانگینِ هزینه در طول یک دنباله از عملیات. یک عمل ممکن است گاهی گران باشد، اما اگر به‌ندرت اتفاق بیفتد، میانگین ارزان می‌ماند. کمی جلوتر دقیق نشانت می‌دهم.

خب، حالا آماده‌ایم.

مدل ذهنی — همه‌چیز فقط دو چیز است

هر مجموعهٔ خطی یک معامله میان همان دو چیدمان بخش ۰ است: حافظهٔ پیوسته (آرایه‌ها) و گره‌های پیوندی (اشاره‌گرها). تقریباً هر چیز دیگری — پشته، صف، deque، جدول هش — یکی از این دو زیربنا به‌علاوهٔ یک سیاست دسترسی است. اگر مدل هزینهٔ این دو را درونی کنی، می‌توانی هزینهٔ هر ساختار سطح‌بالاتری را استخراج کنی.

بیا هزینه‌ها را کنارِ‌هم بگذاریم:

ویژگی پیوسته (آرایه) پیوندی (گره)
دسترسی تصادفی get(i) O(1) O(n)
درج/حذف در انتها O(1) سرشکن O(1)
درج/حذف در ابتدا O(n) O(1)
درج/حذف در وسط (با اندیس / با ارجاع گره) O(n) با ارجاع گره O(1)، برای یافتن O(n)
حافظه به‌ازای هر عنصر یک خانه (+فضای اضافه) عنصر + ۱ تا ۲ اشاره‌گر + header شیء
رفتار cache عالی (سازگار با prefetch) ضعیف (تعقیب اشاره‌گر)

بیا چند سطر را حس کنیم. دسترسی تصادفی: در قفسه، خانهٔ ۵۰ یعنی «آدرسِ شروع + ۵۰ خانه» — یک محاسبهٔ آنی، پس O(1). در زنجیره باید از نفر اول تا پنجاهم جلو بروی، پس O(n). درج در ابتدا: در زنجیره فقط یک دست را عوض می‌کنی، O(1)؛ در قفسه باید همهٔ کتاب‌ها را یک خانه به راست هُل بدهی، O(n).

سطر آخر است که جونیور را از سنیور جدا می‌کند

روی سخت‌افزار امروزی، ArrayList تقریباً در هر بار کاری واقعی از LinkedList بهتر است — حتی برای درج در وسط با اندازه‌های متوسط. چرا؟ چون حافظهٔ ترتیبیِ آرایه prefetch می‌شود، اما تعقیب اشاره‌گرِ لیست پیوندی هر بار یک cache miss است. Big-O برابر است، ولی ثابت‌های پنهانِ سخت‌افزار برنده را عوض می‌کنند. LinkedList در جاوا تقریباً هیچ‌وقت پیش‌فرض درست نیست؛ بدان چرا سراغش می‌روی (حذف/splice با ارجاع گره در O(1)، مثلاً داخل یک LRU) به‌جای طوطی‌وار گفتن Big-O.

آرایه‌های پویا (dynamic arrays)

آرایهٔ خام در جاوا اندازهٔ ثابت دارد؛ وقتی می‌سازی‌اش طولش قفل می‌شود. اما ArrayList انگار بی‌نهایت رشد می‌کند. این جادو چطور کار می‌کند؟

رستورانی که میزهایش را عوض می‌کند

یک رستوران کوچک را تصور کن با ۱۰ صندلی. مشتری‌ها می‌آیند و صندلی‌ها پُر می‌شوند. وقتی صندلی یازدهم لازم شد، صاحب رستوران کل سالن را به جایی بزرگ‌تر منتقل می‌کند با مثلاً ۱۵ صندلی، همهٔ مشتری‌ها را جابه‌جا می‌کند و به کارش ادامه می‌دهد. این کار گران است (باید همه را بلند کنی)، اما به‌ندرت اتفاق می‌افتد. آرایهٔ پویا هم دقیقاً همین است: یک آرایهٔ پشتیبان با اندازهٔ ثابت که وقتی پُر شد، با تخصیص مجدد + کپی (reallocation + copy) به آرایهٔ بزرگ‌تری کوچ می‌کند.

ArrayList جاوا در اولین درج با ظرفیت ۱۰ شروع می‌کند (به‌صورت تنبل؛ آرایهٔ پشتیبانِ ساخته‌شده با سازندهٔ خالی تا اولین add یک آرایهٔ صفرطولِ مشترک است) و با ضریب حدود ۱٫۵ رشد می‌کند:

newCapacity = oldCapacity + (oldCapacity >> 1);

(>> 1 یعنی تقسیم بر ۲، پس این می‌شود «قدیم + نصفِ قدیم = ۱٫۵ برابر».) std::vector در ++C معمولاً دو برابر می‌کند. ضریب ۱٫۵ حافظهٔ کمتری هدر می‌دهد اما کمی بیشتر تخصیص مجدد انجام می‌دهد.

چرا افزودن به انتها سرشکن O(1) است

اینجا همان جایی است که واژهٔ «سرشکن» زنده می‌شود. اگر رشد با ضریب ثابت باشد، کل کارِ کپی در طول n بار افزودن یک سری هندسی می‌شود:

n + n/1.5 + n/1.5² + … ≈ 3n  کپی در مجموع

یعنی برای n بار افزودن، رویِ‌هم حدود 3n کپی انجام می‌دهی. تقسیم بر n، می‌شود حدود ۳ کپی به‌ازای هر افزودن — یک عددِ ثابت، پس O(1) سرشکن. درست است که هر افزودنِ منفردی که resize را تحریک کند O(n) است (باید کل رستوران را جابه‌جا کنی)، ولی این‌قدر نادر است که میانگین ثابت می‌ماند. این مثالِ متعارفِ تحلیل سرشکن (amortized analysis) است — انتظار داشته باش در مصاحبه توضیحش بدهی.

سرشکن یعنی «میانگین در طول زمان»، نه «همیشه»

فرق «سرشکن O(1)» با «بدترین‌حالت O(1)» را با قبض برق بفهم: قبض بیشتر ماه‌ها معمولی است ولی گاهی یک ماهِ گران داری. اگر آن ماه‌های گران کم و پراکنده باشند، میانگینِ ماهانه‌ات پایین می‌ماند. سرشکن دقیقاً همین میانگین است؛ اما آن یک افزودنِ بدشانس که resize را کلید می‌زند، واقعاً O(n) است.

چرا جاوا ۱٫۵ را انتخاب کرد و نه ۲؟ — بحث fragmentation

ضریب رشد روی قطعه‌قطعه شدن حافظه (fragmentation) هم اثر دارد. با ضریب ۲، بلوکِ جدید همیشه بزرگ‌تر از مجموعِ همهٔ بلوک‌های آزادشدهٔ قبلی است، پس تخصیص‌دهنده هرگز نمی‌تواند فضای آزادشده را دوباره استفاده کند و حافظه سوراخ‌سوراخ می‌ماند. ضریب کمتر از ۲ (مثل ۱٫۵ که نزدیک نسبت طلایی است) در نهایت اجازهٔ استفادهٔ مجدد را می‌دهد. این یک دلیل واقعیِ انتخاب ۱٫۵ توسط جاوا است.

پیاده‌سازی از صفر

حالا خودمان یکی بسازیم. به کامنت‌ها دقت کن — هر خط یک درس است:

public final class DynArray<E> {
    private Object[] a;
    private int size;

    public DynArray() { a = new Object[10]; }

    public int size() { return size; }

    @SuppressWarnings("unchecked")
    public E get(int i) {
        Objects.checkIndex(i, size);        // IndexOutOfBoundsException می‌اندازد
        return (E) a[i];
    }

    public void add(E e) {                  // سرشکن O(1)
        if (size == a.length) grow();
        a[size++] = e;
    }

    public void add(int i, E e) {            // O(n) — دنباله را به راست شیفت می‌دهد
        Objects.checkIndex(i, size + 1);
        if (size == a.length) grow();
        System.arraycopy(a, i, a, i + 1, size - i);
        a[i] = e;
        size++;
    }

    @SuppressWarnings("unchecked")
    public E remove(int i) {                 // O(n) — دنباله را به چپ شیفت می‌دهد
        Objects.checkIndex(i, size);
        E old = (E) a[i];
        int moved = size - i - 1;
        if (moved > 0) System.arraycopy(a, i + 1, a, i, moved);
        a[--size] = null;                    // خالی کردن خانه: جلوگیری از loitering (نشت حافظه)
        return old;
    }

    private void grow() {
        int newCap = a.length + (a.length >> 1);   // ۱٫۵ برابر
        a = Arrays.copyOf(a, newCap);
    }
}

چند نکته را با هم بخوانیم. System.arraycopy یک کپیِ سریعِ سطح‌پایین است که کل یک بازه را یک‌جا جابه‌جا می‌کند؛ همان «هُل دادن کتاب‌ها»ی قفسه. در add(int i, ...) دنباله را یک خانه به راست هُل می‌دهیم تا جا باز شود؛ در remove یک خانه به چپ می‌کشیم تا حفره پُر شود — هر دو O(n).

خطِ طلاییِ `a[--size] = null`

این خط ظریف‌ترین و سنیورترین جزئیات کد است. وقتی عنصری را حذف می‌کنی، اگر آن خانه را null نکنی، آرایهٔ پشتیبان همچنان یک ارجاع قویِ کهنه به شیءِ حذف‌شده نگه می‌دارد و GC نمی‌تواند آزادش کند — یعنی همان نشتِ loitering بخش ۰. تو منطقاً حذفش کردی، ولی حافظه‌اش پرسه می‌زند. خودِ ArrayList.remove در جاوا دقیقاً همین null کردن را انجام می‌دهد.

لیست‌های پیوندی (linked lists)

حالا سراغِ زنجیرهٔ آدم‌ها می‌رویم.

پیوندی یک‌طرفه (singly linked)

هر گره یک مقدار و یک اشاره‌گر next نگه می‌دارد (دستی که فقط نفر بعدی را گرفته). یک head نگه می‌داری تا سرِ زنجیره را بشناسی، و معمولاً یک tail هم برای افزودنِ O(1) به انتها.

public class SinglyLinkedList<E> {
    private static final class Node<E> {
        E val; Node<E> next;
        Node(E v) { val = v; }
    }
    private Node<E> head, tail;
    private int size;

    public void addLast(E e) {               // O(1) به‌لطف tail
        Node<E> n = new Node<>(e);
        if (tail == null) head = tail = n;
        else { tail.next = n; tail = n; }
        size++;
    }

    public void addFirst(E e) {               // O(1)
        Node<E> n = new Node<>(e);
        n.next = head;
        head = n;
        if (tail == null) tail = n;
        size++;
    }

    // معکوس‌سازی تکراری — کلاسیک. زمان O(n)، فضای O(1).
    public void reverse() {
        Node<E> prev = null, cur = head;
        tail = head;                          // head قدیمی به tail جدید تبدیل می‌شود
        while (cur != null) {
            Node<E> next = cur.next;          // قبل از خراب کردن ذخیره کن
            cur.next = prev;                  // اشاره‌گر را برگردان
            prev = cur;
            cur = next;
        }
        head = prev;
    }
}
معکوس‌سازی = برگرداندن جهتِ دست‌ها در زنجیره

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

معکوس‌سازی همان سنجشِ دستکاری اشاره‌گر است. قاعدهٔ «قبل از برگرداندن، next را ذخیره کن» باید حافظهٔ عضلانی‌ات باشد. نسخهٔ بازگشتی زیباست اما O(n) پشته مصرف می‌کند (هر فراخوانی یک قاب روی stack) — روی لیست‌های خیلی بلند پشته را سرریز می‌کند، پس در مصاحبه اشاره کن که نسخهٔ تکراریِ O(1)-فضا امن‌تر است.

پیوندی دوطرفه (doubly linked)

حالا به هر گره یک اشاره‌گر prev هم اضافه کن (هرکس هم دستِ جلویی و هم دستِ عقبی را گرفته). این کار دو قدرت می‌دهد: حذفِ O(1) با داشتن ارجاع گره، و پیمایشِ رو به عقب. این دقیقاً چیزی است که یک LRU cache نیاز دارد و همان چیزی که LinkedList جاوا پیاده می‌کند.

static final class DNode<E> {
    E val; DNode<E> prev, next;
    DNode(E v) { val = v; }
}
// جدا کردن یک گرهِ مشخص در O(1):
void unlink(DNode<E> n) {
    if (n.prev != null) n.prev.next = n.next; else head = n.next;
    if (n.next != null) n.next.prev = n.prev; else tail = n.prev;
    n.prev = n.next = null;                   // کمک به GC و جلوگیری از پیمایش تصادفی
}
قدرت واقعی «حذف O(1) وقتی گره را از قبل در دست داری» است

حواست باشد: قدرتِ لیست دوطرفه «حذف O(1)» به‌طور انتزاعی نیست — بلکه حذف O(1) وقتی گره را از قبل داری است. یافتنِ گره همچنان O(n) است، چون باید از سر زنجیره جلو بروی. به همین دلیل ساختارهای واقعی مثل LinkedHashMap و LRU یک نقشهٔ بیرونی از «کلید → گره» نگه می‌دارند: تا با یک جست‌وجوی O(1) در نقشه، گره را پیدا کنند و بعد در O(1) به unlink بدهند. لیست تنهایی کند است؛ ترکیبِ نقشه + لیست است که جادو می‌کند.

پشته‌ها و صف‌ها

حالا به «سیاست‌های دسترسی» می‌رسیم. پشته (stack) ساختار LIFO است (Last In, First Out — آخرین واردشده، اولین خارج‌شده)؛ صف (queue) ساختار FIFO است (First In, First Out). نکتهٔ کلیدی: این‌ها چیدمان نیستند، فقط قانونِ دسترسی‌اند — می‌توانی هرکدام را با آرایه یا لیست پیوندی پشتیبانی کنی.

بشقاب‌ها و صف نانوایی

پشته مثل دستهٔ بشقاب‌های شسته است: آخرین بشقابی که رویش گذاشتی، اولین چیزی است که برمی‌داری (LIFO). صف مثل صف نانوایی است: هرکه اول آمد، اول نان می‌گیرد (FIFO). همین. هر عملیاتِ داده‌ای که «آخری اول» بخواهد پشته است، و هر چیزی که «اولی اول» بخواهد صف.

از ArrayDeque استفاده کن، نه Stack یا LinkedList

  • java.util.Stack از Vector ارث می‌برد — کاملاً synchronized (یعنی هر متد قفل می‌گیرد، پس کند است) و علاوه بر آن دسترسی تصادفی را افشا می‌کند و انتزاعِ «فقط از بالا» را می‌شکند. قدیمی است؛ در کد جدید هرگز استفاده نکن. خودِ Javadoc جاوا استفاده از Deque را توصیه می‌کند.
  • LinkedList رابط Deque را پیاده می‌کند اما تعقیبِ اشاره‌گر دارد و به‌ازای هر عنصر یک شیءِ گره تخصیص می‌دهد (فشار روی GC و cache miss).
  • ArrayDeque یک بافر حلقوی (circular buffer) روی آرایه است، با اندازهٔ توانی از دو، که اندیس‌های head/tail با & (len-1) ماسک می‌شوند. سریع‌ترین پشته و سریع‌ترین صف در JDK است. محدودیت ظرفیت ندارد و عنصر null مجاز نیست.
Deque<Integer> stack = new ArrayDeque<>();
stack.push(1); stack.push(2);          // addFirst
int top = stack.pop();                 // removeFirst -> 2

Deque<Integer> queue = new ArrayDeque<>();
queue.offer(1); queue.offer(2);        // addLast
int head = queue.poll();               // removeFirst -> 1

Deque (تلفظ «دِک»، مخفف double-ended queue یعنی صفِ دوسر) اجازه می‌دهد از هر دو سر اضافه/حذف کنی. push/pop روی سر کار می‌کنند (رفتار پشته)، و offer/poll از دنباله اضافه و از سر حذف می‌کنند (رفتار صف). همان یک شیء، هر دو نقش.

تلهٔ واقعی مصاحبه: چرا `null` در `ArrayDeque` ممنوع است

ArrayDeque از خودِ مقدار null به‌عنوان نگهبانِ خانهٔ خالی (sentinel) استفاده می‌کند؛ یعنی خانه‌ای که مقدارش null است، «خالی» تلقی می‌شود. از طرفی poll/peek وقتی صف تهی است null برمی‌گردانند تا بگویند «چیزی نیست». حالا اگر تو خودت یک null واقعی ذخیره کنی، دیگر معلوم نیست nullی که گرفتی یعنی «مقدارِ ذخیره‌شده» یا «صف خالی است» — ابهام! برای همین کلاً ممنوعش کرده‌اند. در مقابل، LinkedList مقدار null را می‌پذیرد چون خالی/پُر را از طریق size تشخیص می‌دهد.

چرا توانی از دو + ماسک

head و tail دور یک آرایهٔ ثابت می‌پیچند (وقتی به ته آرایه رسیدی، برمی‌گردی سرِ خط). برای این پیچش باید باقی‌ماندهٔ تقسیم بگیری. اما به‌جای index % length که یک تقسیمِ کند است، اگر طول آرایه توانی از دو باشد می‌توانی از index & (length - 1) استفاده کنی — فقط یک عملیاتِ ANDِ بیتی، بسیار سریع‌تر.

چرا AND معجزه می‌کند

اگر length = 8 باشد (توانِ دو)، آنگاه length - 1 = 7 که در دودویی می‌شود 0111. عملیاتِ x & 0111 دقیقاً سه بیتِ پایینِ x را نگه می‌دارد و بقیه را صفر می‌کند — یعنی همان باقی‌ماندهٔ تقسیم بر ۸، ولی با یک دستور به‌جای تقسیم. این ترفند فقط وقتی جواب می‌دهد که طول توانی از دو باشد، برای همین ArrayDeque هنگام resize ظرفیت را دو برابر می‌کند (نه ۱٫۵ برابر) تا این ناوردا حفظ شود.

head = (head - 1) & (elements.length - 1);   // پیچش در addFirst
tail = (tail + 1) & (elements.length - 1);    // پیچش در addLast
// وقتی پس از افزودن head == tail شود پُر است -> آرایه دو برابر می‌شود

بافر حلقوی (ring buffer) از صفر

بیا خودمان یک حلقهٔ با ظرفیت ثابت بسازیم. این ساختار اسبِ بارکشِ صف‌های کران‌دار، خطوط تولیدکننده/مصرف‌کننده (producer/consumer) و بافرهای صوتی/شبکه است.

میدانِ چرخانِ اسب‌های چوبی

یک چرخ‌وفلکِ اسب چوبی را تصور کن با تعداد ثابتی اسب. یک اشاره‌گر می‌گوید «از این اسب سوار کن» (head) و یکی می‌گوید «اسبِ خالی بعدی برای سوار کردن اینجاست» (tail). وقتی به آخر رسیدی، دور می‌زنی و برمی‌گردی اول. مشکل کلاسیک: وقتی head == tail است، از کجا بفهمی چرخ‌وفلک کاملاً پُر است یا کاملاً خالی؟ راه‌حل: یک شمارندهٔ count نگه دار.

public final class RingBuffer<E> {
    private final Object[] buf;
    private int head, tail, count;

    public RingBuffer(int capacity) { buf = new Object[capacity]; }

    public boolean offer(E e) {                 // O(1)؛ اگر پُر باشد false
        if (count == buf.length) return false;
        buf[tail] = e;
        tail = (tail + 1) % buf.length;
        count++;
        return true;
    }

    @SuppressWarnings("unchecked")
    public E poll() {                            // O(1)؛ اگر خالی باشد null
        if (count == 0) return null;
        E e = (E) buf[head];
        buf[head] = null;                        // جلوگیری از loitering
        head = (head + 1) % buf.length;
        count--;
        return e;
    }
    public boolean isFull() { return count == buf.length; }
}

توجه کن که باز هم buf[head] = null را داریم تا از loitering جلوگیری کنیم — همان درسِ آرایهٔ پویا. اینجا از % استفاده کردم چون ظرفیت لزوماً توانی از دو نیست؛ اگر تضمین کنی توانی از دوست، می‌توانی به & (buf.length - 1) سریع‌تر ارتقا بدهی.

جدول‌های هش (hash tables)

اینجا قلبِ عملکردِ هر برنامهٔ بزرگ است. یک جدول هش کلیدها را به اندیسِ سطل نگاشت می‌کند و get/put/remove را با زمانِ مورد انتظارِ O(1) انجام می‌دهد.

کمدِ رختکنِ باشگاه

یک باشگاه با ۱۶ کمد را تصور کن. به‌جای اینکه هر بار همهٔ کمدها را بگردی، یک قاعده داری: «شمارهٔ کمد = باقی‌ماندهٔ کدملی بر ۱۶». حالا برای گذاشتن یا برداشتنِ وسایلِ هرکس، مستقیم می‌روی سراغ کمدِ درست — بدون گشتن. این «قاعده» همان تابع هش (hash function) است، و «شمارهٔ کمد» همان اندیسِ سطل (bucket). در جاوا: index = hash(key) & (capacity - 1).

اما یک مشکل هست: چه می‌شود اگر کدملیِ دو نفر باقی‌ماندهٔ یکسان بدهد؟ هر دو به یک کمد فرستاده می‌شوند. به این می‌گویند برخورد (collision). دو اهرمی که تعیین می‌کنند آیا واقعاً O(1) می‌گیری یا نه، عبارت‌اند از تابع هش (چقدر خوب پخش می‌کند) و مدیریت برخورد.

مدیریت برخورد: زنجیره‌سازی در برابر آدرس‌دهی باز

زنجیره‌سازی جدا (separate chaining) — هر کمد یک لیست (یا درخت) از همهٔ کسانی که به آن کمد افتاده‌اند نگه می‌دارد. HashMap جاوا از این روش استفاده می‌کند. ساده است، ضرایبِ بارِ بالا را تحمل می‌کند، اما هر درایه هزینهٔ یک شیءِ گره و یک تعقیبِ اشاره‌گر دارد.

آدرس‌دهی باز (open addressing) — همهٔ درایه‌ها در خودِ آرایه زندگی می‌کنند؛ هنگام برخورد، برای کمدِ خالیِ بعدی کاوش (probe) می‌کنی (خطی، درجه‌دو، یا هشِ مضاعف). چون شیءِ گرهِ جداگانه نداری، سازگار با cache و کم‌حافظه است، اما با نزدیک شدنِ ضریب بار به ۱ به‌شدت افت می‌کند و حذف نیازمندِ سنگ‌قبر (tombstone) است.

چرا حذف در آدرس‌دهی باز به «سنگ‌قبر» نیاز دارد

تصور کن سه نفر A, B, C همه به کمدِ ۵ افتادند و چون پُر بود، به ترتیب رفتند کمدهای ۶ و ۷ (کاوشِ خطی). حالا اگر B را حذف کنی و کمد ۶ را ساده null کنی، وقتی بعداً دنبال C بگردی، از کمد ۵ شروع می‌کنی، به کمد ۶ می‌رسی، خالی می‌بینی و نتیجه می‌گیری «C وجود ندارد» — در حالی که C در کمد ۷ است! زنجیرهٔ کاوش پاره شد. راه‌حل: به‌جای null، یک نشانهٔ خاص «اینجا قبلاً چیزی بود» بگذار — همان tombstone (سنگ‌قبر) — تا کاوش ادامه پیدا کند. ThreadLocalMap و IdentityHashMap از آدرس‌دهی باز استفاده می‌کنند.

زنجیره‌سازی آدرس‌دهی باز
حافظه بیشتر (گره‌ها) کمتر (آرایهٔ تخت)
cache بدتر بهتر
تحمل ضریب بار تا بالای ۱ باید زیر ~۰٫۷ بماند
حذف ساده نیازمند tombstone
خوشه‌بندی (clustering) ندارد اولیه/ثانویه

(«خوشه‌بندی/clustering» یعنی وقتی چند درایهٔ پشت‌سرِ‌هم پُر می‌شوند و کاوش‌های بعدی مجبورند از روی همهٔ آن‌ها رد شوند — یک رشتهٔ چسبیده که کندی می‌آورد.)

ضریب بار و resize

ضریب بار (load factor) = تعداد درایه ÷ تعداد سطل. یعنی چقدر کمدها شلوغ‌اند. پیش‌فرضِ جاوا ۰٫۷۵ است: توازنی میان فضا (ضریبِ خیلی پایین یعنی کمدهای خالیِ هدررفته) و زمان (ضریبِ خیلی بالا یعنی زنجیره‌های طولانی و کندی). وقتی size > capacity × 0.75، HashMap ظرفیت را دو برابر resize می‌کند و همهٔ درایه‌ها را دوباره هش می‌کند (rehash). ظرفیتِ اولیهٔ پیش‌فرض ۱۶ است.

اگر تعداد را از قبل می‌دانی، از قبل اندازه‌دهی کن

هر resize یک عبورِ کاملِ O(n) روی همهٔ درایه‌هاست — یک مالیاتِ خاموش. اگر می‌دانی N درایه درج خواهی کرد، ظرفیت را از اول کافی بگیر:

new HashMap<>((int)(N / 0.75) + 1)

این‌طوری هرگز به آستانهٔ resize نمی‌رسی و همهٔ آن rehashهای مکرر را حذف می‌کنی.

جزئیات درونی HashMap جاوا (جاوا ۸ به بعد)

  • سطل‌ها یک Node[] هستند. هر سطل تا وقتی طولانی نشده یک لیست پیوندی یک‌طرفه است.
  • درخت‌سازی (treeification): وقتی یک سطل به TREEIFY_THRESHOLD = 8 درایه می‌رسد و ظرفیت کل ≥ MIN_TREEIFY_CAPACITY = 64 است، آن سطل به یک درخت قرمز-سیاه (red-black tree) تبدیل می‌شود و بدترین‌حالتِ جست‌وجوی سطل را از O(n) به O(log n) می‌برد. زیر ظرفیت ۶۴، به‌جای درخت‌سازی، اول resize می‌کند. در UNTREEIFY_THRESHOLD = 6 دوباره به لیست untreeify می‌شود.
  • HashMap هش را پخش (spread) می‌کند: h = key.hashCode(); h ^= (h >>> 16);.
hysteresis: چرا آستانهٔ رفت ۸ است ولی برگشت ۶

چرا برای تبدیل به درخت از ۸ استفاده می‌کند ولی برای برگشت به لیست از ۶، نه هر دو از ۷؟ مثل ترموستاتِ کولر فکر کن: اگر روشن‌شدن و خاموش‌شدن هر دو دقیقاً روی ۲۵ درجه باشد، کولر دورِ همان دما مدام روشن/خاموش می‌شود و می‌لرزد. برای همین یک فاصله می‌گذاری: روشن روی ۲۶، خاموش روی ۲۴. به این فاصله hysteresis (پسماند) می‌گویند و از نوسانِ بی‌فایده دورِ مرز جلوگیری می‌کند. آستانه‌های ۸ و ۶ در HashMap دقیقاً همین‌اند.

چرا `h ^ (h >>> 16)` — نجاتِ بیت‌های بالا

اندیسِ سطل hash & (capacity - 1) است که برای ظرفیت‌های کوچک فقط بیت‌های پایینِ هش را نگاه می‌کند و بیت‌های بالا را دور می‌ریزد. حالا فرض کن یک hashCode عمدتاً در بیت‌های بالا تفاوت دارد و بیت‌های پایینش شبیهِ هم‌اند — آنگاه همهٔ کلیدها در یک سطل برخورد می‌کنند! ترفند h ^ (h >>> 16) بیت‌های بالا را با XOR می‌ریزد داخل بیت‌های پایین، پس آن آنتروپی (تنوع) در اندیسِ ماسک‌شده حفظ می‌شود. به همین دلیل یک hashCode بد (مثلاً همیشه یک مقدار ثابت) کل نقشه را به یک تک درخت/لیست فرو می‌ریزد — در بهترین حالت O(log n)، و با کلیدِ غیرِ Comparable برابرِ O(n).

قرارداد `equals`/`hashCode` — باگِ کلاسیکِ تولید

دو شیءِ برابر (equals) باید hashCode یکسان داشته باشند. اگر این را نقض کنی، کلیدی که put کرده‌ای غیرقابل یافتن می‌شود، چون get آن را به سطلی متفاوت هش می‌کند. بدتر: اگر فیلدی را که در hashCode استفاده شده، بعد از درج تغییر بدهی، درایه در سطلِ اشتباه گیر می‌افتد و گم می‌شود. درسِ عملی: کلیدهای نقشه را روی فیلدهای hashCode/equals تغییرناپذیر (immutable) بساز.

یک HashMap زنجیره‌ای مینیمال از صفر

public final class MyHashMap<K, V> {
    private static final class Node<K, V> {
        final int hash; final K key; V val; Node<K, V> next;
        Node(int h, K k, V v, Node<K, V> n) { hash = h; key = k; val = v; next = n; }
    }
    private Node<K, V>[] table;
    private int size;
    private static final float LOAD_FACTOR = 0.75f;

    @SuppressWarnings("unchecked")
    public MyHashMap() { table = new Node[16]; }

    private static int spread(Object k) {          // تقلید از پخش هشِ HashMap
        int h = (k == null) ? 0 : k.hashCode();
        return h ^ (h >>> 16);
    }
    private int indexFor(int hash) { return hash & (table.length - 1); }

    public V put(K key, V val) {
        int h = spread(key), i = indexFor(h);
        for (Node<K, V> e = table[i]; e != null; e = e.next) {
            if (e.hash == h && Objects.equals(e.key, key)) {  // اتصال کوتاهِ hash پیش از equals
                V old = e.val; e.val = val; return old;
            }
        }
        table[i] = new Node<>(h, key, val, table[i]);         // افزودن به ابتدا
        if (++size > table.length * LOAD_FACTOR) resize();
        return null;
    }

    public V get(K key) {
        int h = spread(key);
        for (Node<K, V> e = table[indexFor(h)]; e != null; e = e.next)
            if (e.hash == h && Objects.equals(e.key, key)) return e.val;
        return null;
    }

    @SuppressWarnings("unchecked")
    private void resize() {
        Node<K, V>[] old = table;
        table = new Node[old.length << 1];                    // دو برابر -> توانی از دو بماند
        for (Node<K, V> head : old)
            for (Node<K, V> e = head; e != null; ) {
                Node<K, V> next = e.next;
                int i = indexFor(e.hash);                     // با طول جدید بازمحاسبه
                e.next = table[i]; table[i] = e;              // دوباره افزودن به ابتدا
                e = next;
            }
    }
}
چرا `e.hash == h` پیش از `equals`

به آن بررسیِ e.hash == h قبل از equals دقت کن. مقایسهٔ دو عددِ صحیحِ کش‌شده تقریباً رایگان است، اما equals می‌تواند گران باشد (مثلاً مقایسهٔ حرف‌به‌حرفِ دو رشتهٔ بلند). با این ترتیب، وقتی هش‌ها فرق دارند، اصلاً وارد equals نمی‌شویم — یک اتصالِ کوتاهِ (short-circuit) ارزان که اولین خط دفاع در برابر مقایسه‌های پرهزینه هنگام برخورد است.

مسائل کلاسیک

حالا با همان زیربناها، مسائلی را می‌سازیم که در هر مصاحبهٔ سنیور می‌بینی.

تشخیص حلقه — لاک‌پشت و خرگوش فلوید (Floyd)

مسابقهٔ دو نفر روی پیست

دو دونده روی یک پیست بگذار: یکی آرام (یک قدم در هر لحظه)، یکی تند (دو قدم). اگر پیست یک مسیرِ باز باشد، دوندهٔ تند زودتر به انتها می‌رسد و تمام. اما اگر پیست یک حلقهٔ بسته باشد، دوندهٔ تند مدام دور می‌زند و سرانجام از پشت به دوندهٔ آرام می‌رسد — یعنی به هم می‌خورند. پس «به‌هم‌رسیدن = وجودِ حلقه». همین ایدهٔ ساده حلقه را در فضای O(1) پیدا می‌کند.

دو اشاره‌گر، یکی یک قدم و دیگری دو قدم. اگر حلقه باشد سرانجام به هم می‌رسند؛ اگر نباشد، اشاره‌گرِ سریع به null می‌رسد. زمان O(n)، فضای O(1) (که رویکردِ O(n)-فضای مبتنی بر hash-set را می‌زند).

static <E> boolean hasCycle(Node<E> head) {
    Node<E> slow = head, fast = head;
    while (fast != null && fast.next != null) {
        slow = slow.next;              // +۱
        fast = fast.next.next;         // +۲
        if (slow == fast) return true; // برخورد اشاره‌گرها => حلقه
    }
    return false;
}

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

پرانتزهای معتبر — پشته

بشقاب‌های تودرتو

هر پرانتزِ باز مثل گذاشتنِ یک بشقاب روی دسته است. هر پرانتزِ بسته باید با بالاترین بشقاب جور دربیاید و آن را بردارد. اگر بشقابی که برمی‌داری از جنسِ درستی نباشد (مثلاً ( را با ] ببندی)، نامعتبر است. و اگر آخرِ کار هنوز بشقابی روی دسته مانده باشد، یعنی پرانتزی باز مانده — باز هم نامعتبر. این دقیقاً کارِ یک پشته است.

static boolean isValid(String s) {
    Deque<Character> st = new ArrayDeque<>();
    Map<Character, Character> pair = Map.of(')', '(', ']', '[', '}', '{');
    for (char c : s.toCharArray()) {
        if (pair.containsValue(c)) st.push(c);           // بازکننده
        else if (pair.containsKey(c)) {                  // بستن‌کننده
            if (st.isEmpty() || st.pop() != pair.get(c)) return false;
        }
    }
    return st.isEmpty();                                 // بازکننده‌های باقی‌مانده = نامعتبر
}

زمان O(n)، فضای O(n). دو حالتِ شکست: بستن‌کننده‌ای که با بالای پشته مطابق نیست، و بازکننده‌های باقی‌مانده در انتها. جا انداختنِ بررسیِ نهاییِ isEmpty() رایج‌ترین باگ است — کد را برای ورودیِ "(" امتحان کن و ببین.

min-stack — getMin در O(1)

برچسبِ «کمینه تا اینجا» روی هر بشقاب

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

class MinStack {
    private final Deque<Integer> data = new ArrayDeque<>();
    private final Deque<Integer> mins = new ArrayDeque<>();
    public void push(int x) {
        data.push(x);
        mins.push(mins.isEmpty() ? x : Math.min(x, mins.peek()));
    }
    public void pop()      { data.pop(); mins.pop(); }
    public int  top()      { return data.peek(); }
    public int  getMin()   { return mins.peek(); }   // O(1)
}

یک نسخهٔ بهینه از نظر فضا، کمینه را فقط هنگامِ تغییر ذخیره می‌کند و تکراری‌ها را با دقت مدیریت می‌کند — به آن اشاره کن، اما نسخهٔ دو-پشته‌ای بدونِ باگ و مطمئن برای مصاحبه است.

LRU cache — نقشهٔ هش + لیست پیوندی دوطرفه

میزِ کارِ محدود

LRU مخفف Least Recently Used است: «کم‌استفاده‌ترینِ اخیر». مثل یک میزِ کارِ کوچک که فقط چند پرونده رویش جا می‌شود. هر پرونده‌ای که تازه به آن دست زدی، می‌گذاری‌اش بالای دسته (head). وقتی میز پُر شد و پروندهٔ جدیدی آمد، آن پرونده‌ای را که از همه بیشتر مدت است بهش دست نزده‌ای — یعنی تهِ دسته (tail) — دور می‌اندازی. برای O(1) بودنِ همه‌چیز، دو ابزار را ترکیب می‌کنی: یک HashMap برای «کجاست؟»، و یک لیست پیوندیِ دوطرفه برای «ترتیبِ تازگی».

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

class LRUCache {
    private static final class Node { int key, val; Node prev, next; Node(int k,int v){key=k;val=v;} }
    private final int cap;
    private final Map<Integer, Node> map = new HashMap<>();
    private final Node head = new Node(0,0), tail = new Node(0,0);   // نگهبان‌ها (sentinels)

    LRUCache(int capacity) { cap = capacity; head.next = tail; tail.prev = head; }

    private void remove(Node n){ n.prev.next = n.next; n.next.prev = n.prev; }
    private void addFront(Node n){ n.next = head.next; n.prev = head; head.next.prev = n; head.next = n; }

    public int get(int key) {
        Node n = map.get(key);
        if (n == null) return -1;
        remove(n); addFront(n);                 // دسترسی => انتقال به جلو
        return n.val;
    }
    public void put(int key, int value) {
        Node n = map.get(key);
        if (n != null) { n.val = value; remove(n); addFront(n); return; }
        if (map.size() == cap) {                // تخلیهٔ LRU (tail)
            Node lru = tail.prev;
            remove(lru); map.remove(lru.key);
        }
        Node fresh = new Node(key, value);
        addFront(fresh); map.put(key, fresh);
    }
}
ترفندِ گره‌های نگهبان (sentinel)

دو گرهِ نگهبان (head و tail) که هیچ داده‌ای ندارند و فقط دو سرِ لیست‌اند، هر بررسیِ null را حذف می‌کنند. چون هر گرهِ واقعی همیشه یک prev و next غیرِ null دارد، متدهای remove/addFront به هیچ حالتِ خاصی برای «عنصر اول» یا «عنصر آخر» نیاز ندارند. همین ترفند کد را تمیز و بی‌باگ می‌کند و مصاحبه‌کننده‌ها دقیقاً مراقبِ آن هستند. در تولید، LinkedHashMap با accessOrder=true و بازنویسیِ removeEldestEntry یک LRU در حدود ۵ خط به تو می‌دهد — هم پاسخِ از-صفر و هم پاسخِ کتابخانه‌ای را بدان.

بهترین شیوه‌ها

  • پیش‌فرض را ArrayList و ArrayDeque بگذار. سراغِ LinkedList فقط وقتی برو که به‌طور اثبات‌پذیر نیاز به spliceِ گره‌ای O(1) با ارجاعِ در دست داری.
  • وقتی تعداد معلوم است، مجموعه‌ها را از قبل اندازه‌دهی کن: new ArrayList<>(n)، new HashMap<>((int)(n/0.75)+1). resize مالیاتِ خاموشِ O(n) است.
  • در کد جدید هرگز از java.util.Stack یا Vector استفاده نکن — کلاس‌های قدیمیِ synchronized.
  • همیشه hashCode و equals را با هم و از فیلدهای یکسان پیاده کن، و کلیدهای نقشه را روی آن فیلدها تغییرناپذیر (immutable) بساز.
  • در ساختارهای مبتنی بر آرایه هنگام حذف، خانه را null کن تا از نشتِ loitering جلوگیری شود.
  • برای همزمانی، wrap نکن — از ConcurrentHashMap، ConcurrentLinkedQueue، ArrayBlockingQueue استفاده کن، نه Collections.synchronizedMap.

پرسش‌های مصاحبه

۱. چرا `ArrayList.add` سرشکن O(1) است اما بدترین‌حالتش O(1) نیست؟

رشد با ضریب ثابت (۱٫۵×) کل کارِ کپی در طول n افزودن را به یک سری هندسی با مجموع حدود 3n تبدیل می‌کند، یعنی به‌طور میانگین O(1) به‌ازای هر افزودن. اما هر افزودنِ منفردی که resize را تحریک کند همهٔ n عنصر را کپی می‌کند — آن یکی O(n) است. سرشکن (میانگین) با بدترین‌حالت فرق دارد؛ اینجا میانگین ثابت است ولی یک عملِ منفرد می‌تواند خطی باشد.

۲. (سخت) چرا جاوا ضریب رشد ۱٫۵ را به‌جای ۲ انتخاب کرد؟

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

۳. `ArrayList` در برابر `LinkedList` برای صفی با حذف‌های زیاد از سر — و آیا پاسخِ کتابی درست است؟

کتابی: LinkedList (حذفِ سر O(1)) از ArrayList (شیفتِ سر O(n)) بهتر است. پاسخِ واقعی: هیچ‌کدام — از ArrayDeque استفاده کن، یک بافرِ حلقوی با O(1) در هر دو سر و حافظهٔ پیوستهٔ سازگار با cache که در عمل با وجودِ Big-O برابر از LinkedList بهتر است، چون تعقیبِ اشاره‌گر cache miss تولید می‌کند.

۴. چرا نمی‌توان `null` در `ArrayDeque` ذخیره کرد؟

از null به‌عنوان نگهبانِ خانهٔ خالی (sentinel) استفاده می‌کند و poll/peek مقدار null را به‌معنای «خالی» برمی‌گردانند. یک nullِ ذخیره‌شده از «خالی» قابل تشخیص نخواهد بود. LinkedList مقدار null را می‌پذیرد چون از طریق size تمایز می‌گذارد.

۵. وقتی یک سطل در `HashMap.put` طولانی می‌شود چه اتفاقی می‌افتد؟

درایه‌ها در یک لیست پیوندیِ یک‌طرفه زنجیر می‌شوند. وقتی یک سطل به ۸ درایه می‌رسد و ظرفیت جدول ≥ ۶۴ است، به درخت قرمز-سیاه تبدیل می‌شود (جست‌وجوی سطل از O(n) به O(log n)). زیرِ ظرفیت ۶۴، به‌جای آن resize می‌کند. در ۶ درایه دوباره به لیست untreeify می‌شود. ضریب بار ۰٫۷۵ وقتی size از cap×۰٫۷۵ فراتر رود، resizeِ دوبرابریِ ظرفیت را تحریک می‌کند.

۶. (سخت) چرا `HashMap` بیت‌های بالای هش را XOR می‌کند داخل بیت‌های پایین (`h ^ (h >>> 16)`)؟

اندیسِ سطل hash & (capacity-1) است که برای ظرفیت‌های کوچک فقط بیت‌های پایینِ هش را استفاده می‌کند. اگر یک hashCode عمدتاً در بیت‌های بالا تغییر کند، همهٔ کلیدها در یک سطل برخورد می‌کنند. پخشِ بیت‌های بالا به پایین، آن آنتروپی را در اندیسِ ماسک‌شده حفظ می‌کند.

۷. زنجیره‌سازی در برابر آدرس‌دهی باز — کِی آدرس‌دهی باز را انتخاب می‌کنی؟

وقتی حافظه و محلیتِ cache مهم است و ضریب بار خیلی زیر ~۰٫۷ می‌ماند: آدرس‌دهی باز درایه‌ها را درجا (بدون شیءِ گره) ذخیره می‌کند، پس سازگار با cache و فشرده است. معایب: نزدیک پُر شدن به‌شدت افت می‌کند، از خوشه‌بندی رنج می‌برد و حذف نیازمند tombstone است. زنجیره‌سازی ضرایبِ بارِ بالا و حذفِ ساده را تحمل می‌کند اما هزینهٔ حافظهٔ گره و تعقیبِ اشاره‌گر می‌پردازد. HashMap جاوا زنجیره‌ای است؛ ThreadLocalMap/IdentityHashMap آدرس‌دهی باز.

۸. (تله) یک همکار فیلدِ به‌کاررفته در `hashCode` کلید نقشه را پس از `put` تغییر می‌دهد. چه چیزی می‌شکند؟

اکنون get به سطلی متفاوت از محلِ درایه هش می‌کند، پس کلید غیرقابلِ یافتن می‌شود هرچند هنوز در نقشه است — نشت/گم‌شدنِ خاموش. کلیدهای نقشه باید روی فیلدهای hashCode/equals عملاً تغییرناپذیر باشند. هم‌رده: نقضِ قرارداد equals↔hashCode باعث می‌شود کلیدهای برابر در سطل‌های متفاوت بیفتند.

۹. الگوریتم شروع حلقهٔ فلوید را اثبات کن.

فرض کن فاصلهٔ head تا ورودی a، ورودی تا برخورد b و بقیهٔ حلقه c باشد (پس طول حلقه = b+c). هنگام برخورد، slow مسافتِ a+b و fast مسافتِ a+b+k(b+c) را طی کرده و برابرِ 2(a+b) است. حلِ معادله a = c + (k-1)(b+c) می‌دهد، یعنی a ≡ c (mod طول حلقه). پس یک اشاره‌گر از head و یک اشاره‌گر از نقطهٔ برخورد، هر یک با گام ۱، دقیقاً در ورودی به هم می‌رسند.

۱۰. (باگ را بیاب) این `isValid` برای `"("` مقدار `true` برمی‌گرداند. چرا؟

اگر کد بدونِ بررسیِ نهاییِ st.isEmpty() برگردد — فقط مطابقتِ هر بستن‌کننده را وارسی کند — بازکننده‌های باقی‌مانده رد می‌شوند. اصلاح: return st.isEmpty() در انتها. باگِ متقارن: بررسی نکردنِ st.isEmpty() پیش از pop() روی یک بستن‌کننده، برای ")" کرش/منفیِ‌کاذب می‌دهد.

۱۱. (چه چاپ می‌شود؟)
Map<Integer,String> m = new HashMap<>();
m.put(1,"a"); m.put(1,"b");
System.out.println(m.size() + " " + m.get(1));

1 b. put با کلیدِ موجود مقدار را بازنویسی می‌کند و قبلی را برمی‌گرداند؛ size برابر ۱ می‌ماند. در تضاد با یک Multimap یا computeIfAbsent.

۱۲. چرا پیاده‌سازی‌های LRU از لیست دوطرفه (نه یک‌طرفه) استفاده می‌کنند، و گره‌های نگهبان برای چیستند؟

هنگام برخورد در cache باید یک گره را از وسط در O(1) جدا کنی که نیازمندِ اشاره‌گرِ prev است — یک‌طرفه برای یافتنِ پیشین O(n) می‌شد. نگهبان‌های head/tail همهٔ بررسی‌های null را حذف می‌کنند: هر گرهِ واقعی همیشه prev و nextِ غیرِ null دارد، پس remove/addFront نیازی به حالتِ خاص برای عنصر اول/آخر ندارند.

۱۳. (سخت) به یک بافرِ تولیدکننده/مصرف‌کنندهٔ با اندازهٔ ثابت و thread-safe نیاز داری. سراغِ چه می‌روی و چرا نه یک ring buffer خام؟

ArrayBlockingQueue — یک بافرِ حلقویِ کران‌دار با یک قفل و دو Condition (notFull، notEmpty) که put/takeِ مسدودکننده فراهم می‌کند. ساختنِ ring bufferِ دستیِ خودت بخشِ سخت را از قلم می‌اندازد: معناشناسیِ صحیحِ مسدودسازی/بیدارسازی و visibility حافظه. برای throughputِ بالاتر، یک حلقهٔ بدونِ قفل (LMAX Disruptor) یا ConcurrentLinkedQueue (نامحدود، مبتنی بر Michael-Scott).

۱۴. تفاوت معناشناسیِ `Deque.push/pop` با `offer/poll` چیست و چرا مهم است؟

push/pop روی سر عمل می‌کنند (پشته، LIFO)؛ offer/poll به دنباله می‌افزایند و از سر حذف می‌کنند (صف، FIFO). یک شیءِ ArrayDeque واحد به هر دو خدمت می‌کند — متدی که فراخوانی می‌کنی نظم را تعیین می‌کند. مخلوط کردنشان (مثلاً push سپس poll) به‌خاموشی رفتارِ FIFO-روی-پشته می‌دهد؛ منبعِ رایجِ باگِ «پشتهٔ من مثلِ صف رفتار می‌کند».

۱۵. (سخت) درخت‌سازی چگونه با یک `hashCode` که مقدار ثابت برمی‌گرداند، برای کلیدهایی که `Comparable` نیستند، تعامل می‌کند؟

همهٔ کلیدها در یک سطل برخورد می‌کنند که درخت‌سازی می‌شود. اما درخت قرمز-سیاه به یک ترتیب نیاز دارد؛ برای کلیدهای غیرِ Comparable، HashMap به مقایسهٔ System.identityHashCode (یک ترتیبِ «tie-breaker») عقب می‌نشیند که دلخواه اما سازگار است — پس جست‌وجوها O(log n) می‌شوند نه O(n)، اما ترتیبِ معنادار را از دست می‌دهی و چیز دیگری به دست نمی‌آوری. اصلاحِ واقعی یک hashCodeِ مناسب است؛ درخت‌سازی کنترلِ خسارت است نه جایگزین.

جمع‌بندی
  • همه‌چیز فقط دو زیربناست: حافظهٔ پیوسته (آرایه، دسترسیِ تصادفیِ O(1)، درجِ وسط O(n)) و گره‌های پیوندی (درج/حذفِ O(1) با ارجاع، دسترسیِ O(n)). سطرِ cache برندهٔ واقعی را تعیین می‌کند.
  • آرایهٔ پویا با ضریب ۱٫۵ رشد می‌کند و add را سرشکن O(1) می‌کند (سری هندسیِ ~3n). هنگام حذف، خانه را null کن تا loitering نشود.
  • لیست پیوندی: معکوس‌سازی = رقصِ سه‌اشاره‌گری؛ دوطرفه = حذفِ O(1) وقتی گره را در دست داری.
  • پشته و صف فقط سیاست‌اند؛ همیشه ArrayDeque (بافرِ حلقوی، توانی از دو + ماسک، بدونِ null) را به Stack/LinkedList ترجیح بده.
  • جدول هش: index = hash & (cap-1)، ضریب بار ۰٫۷۵، درخت‌سازی در ۸ و بازگشت در ۶ (hysteresis)، پخشِ هش h ^ (h >>> 16)، و قرارداد مقدسِ equals↔hashCode.
  • مسائلِ کلاسیک: فلوید (O(1) فضا)، پرانتزِ معتبر (بررسیِ نهاییِ isEmpty!)، min-stackِ دو-پشته‌ای، و LRU = HashMap + لیست دوطرفه با گره‌های نگهبان. اگر این‌ها را حس کردی، دیگر هیچ ساختارِ خطی‌ای نمی‌تواند در مصاحبه غافلگیرت کند.

Hey. This lesson is the backbone of every program you've ever written. Every list you've iterated, every Map you've filled, every queue you've pushed messages into — they're all built from a handful of structures. The good news: behind all the scary names sit just two ideas. Master those two, and you can derive everything else instead of memorizing Big-O tables.

Let's walk through it together, step by step. I won't drop a single piece of jargon without unpacking it.

Roadmap for this lesson

Here's what you'll learn:

  1. The two physical substrates — contiguous memory (arrays) vs linked nodes (pointers), and each one's cost model.
  2. Dynamic arrays — why ArrayList feels magic and why add is amortized O(1).
  3. Linked lists — singly and doubly, plus the art of reversal and node unlinking.
  4. Stacks and queues — why ArrayDeque beats Stack and LinkedList, and a ring buffer from scratch.
  5. Hash tables — collisions, load factor, treeification, and a hand-built HashMap.
  6. Classic problems — Floyd, valid parentheses, min-stack, LRU.
  7. Interview questions — 15 real ones with full answers.

Part 0 — words you must know

Before we start, let's unpack a few words so we never stumble later.

A bookshelf vs a human chain

Picture two ways to keep things in order. Way one: a tall bookshelf with numbered, side-by-side slots. To reach book number 50 you just jump straight to slot 50. But to squeeze a book into the middle, you must shove every later book over by one. Way two: a chain of people, each holding the next person's hand. To reach the fiftieth person you walk down from the front, one by one. But to insert someone in the middle, you just release and re-join two hands. Shelf = array; chain = linked list. This whole lesson is the tension between those two.

  • Contiguous memory: cells that sit right next to each other in memory, like shelf slots.
  • Pointer (reference): an address saying "the next object is over there", like the hand holding the next person.
  • Cache and prefetch: when the CPU reads one memory cell, it guesses you probably want the neighbors too, so it fetches them ahead of time. That's "prefetch". Contiguous memory loves it; random jumps don't.
  • Cache miss: when the data you want isn't in the fast cache and you must reach out to slow, distant RAM — expensive. Pointer chasing is full of cache misses.
  • GC (garbage collector): the part of Java that reclaims memory of objects nothing references anymore. Hold onto a stale reference and GC can't free it.
  • Loitering: a memory leak where an object is logically removed but a stale reference still clings to it, so GC won't free it.
  • Amortized: the average cost across a sequence of operations. One operation may sometimes be expensive, but if that's rare, the average stays cheap. I'll show you precisely soon.

Okay — now we're ready.

Mental model — everything is really just two things

Every linear collection is a trade between the two layouts from Part 0: contiguous memory (arrays) and linked nodes (pointers). Almost everything else — stacks, queues, deques, hash tables — is one of these two substrates plus an access policy. Internalize the cost model of the two, and you can derive the cost of any higher-level structure.

Let's lay the costs side by side:

Property Contiguous (array) Linked (nodes)
Random access get(i) O(1) O(n)
Insert/remove at end O(1) amortized O(1)
Insert/remove at front O(n) O(1)
Insert/remove in middle (index known, ref known) O(n) O(1) with node ref, O(n) to find
Memory per element 1 slot (+slack) element + 1–2 pointers + object header
Cache behavior excellent (prefetch-friendly) poor (pointer chasing)

Feel a couple of rows. Random access: on the shelf, slot 50 means "start address + 50 slots" — an instant calculation, so O(1). In the chain you must walk from person one to fifty, so O(n). Insert at front: in the chain you swap one hand, O(1); on the shelf you shove every book right by one, O(n).

The last row is what separates junior from senior answers

On modern hardware, ArrayList beats LinkedList for almost every real workload — even middle insertions at moderate sizes. Why? The array's sequential memory is prefetched, but the linked list's pointer chasing is a cache miss every hop. The Big-O is equal, but the hidden hardware constants flip the winner. LinkedList in Java is almost never the right default; know why you'd reach for it (O(1) splice/remove given a node reference, e.g. inside an LRU) rather than reciting Big-O.

Dynamic arrays

A raw array in Java has a fixed size; once you create it, the length is locked. Yet ArrayList seems to grow forever. How does the magic work?

A restaurant that swaps rooms

Picture a small restaurant with 10 seats. Guests arrive, seats fill. When an eleventh guest needs a seat, the owner moves the whole room to a bigger one with, say, 15 seats, relocates every guest, and carries on. That move is expensive (you must lift everyone), but it happens rarely. A dynamic array is exactly this: a fixed-size backing array that, when full, migrates to a larger one via reallocation + copy.

Java's ArrayList starts at capacity 10 on first insert (lazily; the empty-constructed backing array is a shared zero-length array until the first add) and grows by ~1.5×:

newCapacity = oldCapacity + (oldCapacity >> 1);

(>> 1 means divide by 2, so this reads "old + half of old = 1.5×".) C++'s std::vector typically doubles. The 1.5× factor wastes less memory but reallocates slightly more often.

Why append is amortized O(1)

Here's where the word "amortized" comes alive. Growth by a constant factor makes the total copy work across n appends a geometric series:

n + n/1.5 + n/1.5² + … ≈ 3n  total copies

So for n appends you do about 3n copies overall. Divide by n and that's about 3 copies per append — a constant, hence O(1) amortized. True, any single append that triggers a resize is O(n) (you had to move the whole restaurant), but it's rare enough that the average stays flat. This is the canonical example of amortized analysis — expect to explain it in an interview.

Amortized means "average over time", not "always"

Understand the difference between "amortized O(1)" and "worst-case O(1)" through your electric bill: most months are normal, but occasionally one is expensive. If the pricey months are few and scattered, your monthly average stays low. Amortized is exactly that average; but the one unlucky append that flips a resize really is O(n).

Why did Java pick 1.5, not 2? — the fragmentation angle

The growth factor also affects memory fragmentation. With factor 2, the new block is always larger than the sum of all previously freed blocks, so the allocator can never reuse freed space — memory stays riddled with holes. Factor < 2 (like 1.5, golden-ratio-ish) eventually allows reuse. This is a genuine reason Java chose 1.5.

From scratch

Let's build one ourselves. Read the comments — each line is a lesson:

public final class DynArray<E> {
    private Object[] a;
    private int size;

    public DynArray() { a = new Object[10]; }

    public int size() { return size; }

    @SuppressWarnings("unchecked")
    public E get(int i) {
        Objects.checkIndex(i, size);        // throws IndexOutOfBoundsException
        return (E) a[i];
    }

    public void add(E e) {                  // amortized O(1)
        if (size == a.length) grow();
        a[size++] = e;
    }

    public void add(int i, E e) {            // O(n) — shifts tail right
        Objects.checkIndex(i, size + 1);
        if (size == a.length) grow();
        System.arraycopy(a, i, a, i + 1, size - i);
        a[i] = e;
        size++;
    }

    @SuppressWarnings("unchecked")
    public E remove(int i) {                 // O(n) — shifts tail left
        Objects.checkIndex(i, size);
        E old = (E) a[i];
        int moved = size - i - 1;
        if (moved > 0) System.arraycopy(a, i + 1, a, i, moved);
        a[--size] = null;                    // null the slot: avoid loitering (memory leak)
        return old;
    }

    private void grow() {
        int newCap = a.length + (a.length >> 1);   // 1.5x
        a = Arrays.copyOf(a, newCap);
    }
}

A couple of notes. System.arraycopy is a fast low-level bulk move of a whole range — the "shove the books" of the shelf. In add(int i, ...) we shove the tail one slot right to open a gap; in remove we pull it one slot left to fill the hole — both O(n).

The golden line `a[--size] = null`

This is the subtlest, most senior detail in the code. When you remove an element, if you don't null that slot, the backing array still holds a stale strong reference to the removed object and GC can't free it — the loitering leak from Part 0. You logically deleted it, yet its memory loiters. Java's own ArrayList.remove does exactly this nulling.

Linked lists

Now to the human chain.

Singly linked

Each node holds a value and a next pointer (a hand that only grasps the next person). You keep a head to know the chain's front, and usually a tail too for O(1) append.

public class SinglyLinkedList<E> {
    private static final class Node<E> {
        E val; Node<E> next;
        Node(E v) { val = v; }
    }
    private Node<E> head, tail;
    private int size;

    public void addLast(E e) {               // O(1) thanks to tail
        Node<E> n = new Node<>(e);
        if (tail == null) head = tail = n;
        else { tail.next = n; tail = n; }
        size++;
    }

    public void addFirst(E e) {               // O(1)
        Node<E> n = new Node<>(e);
        n.next = head;
        head = n;
        if (tail == null) tail = n;
        size++;
    }

    // Iterative reversal — the classic. O(n) time, O(1) space.
    public void reverse() {
        Node<E> prev = null, cur = head;
        tail = head;                          // old head becomes new tail
        while (cur != null) {
            Node<E> next = cur.next;          // save before we clobber
            cur.next = prev;                  // flip the pointer
            prev = cur;
            cur = next;
        }
        head = prev;
    }
}
Reversal = flipping the direction of hands in the chain

Picture a chain where each person holds the person in front of them. You want to reverse it so everyone holds the person behind instead. If you just start flipping hands, you lose track of who came next and the chain snaps. The trick: before you flip anyone's hand, first remember who the next person was (save next), then turn their hand around. That's the three-pointer dance: prev, cur, next.

Reversal is the pointer-manipulation litmus test. The rule "save next before you flip" must be muscle memory. The recursive version is elegant but uses O(n) stack (one frame per call) — on very long lists it blows the stack, so mention that the iterative O(1)-space version is safer.

Doubly linked

Now add a prev pointer to each node too (everyone holds both the front hand and the back hand). This buys two powers: O(1) removal given a node reference, and backward traversal. It's exactly what an LRU cache needs, and what Java's LinkedList implements.

static final class DNode<E> {
    E val; DNode<E> prev, next;
    DNode(E v) { val = v; }
}
// Unlink a known node in O(1):
void unlink(DNode<E> n) {
    if (n.prev != null) n.prev.next = n.next; else head = n.next;
    if (n.next != null) n.next.prev = n.prev; else tail = n.prev;
    n.prev = n.next = null;                   // help GC, prevent accidental traversal
}
The real power is "O(1) remove *when you already hold the node*"

Be careful: the doubly linked list's power isn't "O(1) remove" in the abstract — it's O(1) remove when you already hold the node. Finding the node is still O(n), because you must walk from the front. That's why real structures like LinkedHashMap and LRU keep an external map from key → node: so one O(1) map lookup finds the node, then unlink runs in O(1). The list alone is slow; it's the map + list combination that makes the magic.

Stacks and queues

Now we reach "access policies". A stack is LIFO (Last In, First Out); a queue is FIFO (First In, First Out). The key point: these aren't layouts, just access rules — you can back either with an array or a linked list.

Plates and the bakery line

A stack is like a pile of washed plates: the last plate you set down is the first you take (LIFO). A queue is like the bakery line: first to arrive, first to get bread (FIFO). That's it. Any data operation that wants "last in first" is a stack; anything that wants "first in first" is a queue.

Use ArrayDeque, not Stack or LinkedList

  • java.util.Stack extends Vector — fully synchronized (every method takes a lock, so it's slow) and it also exposes random access, breaking the "top only" abstraction. Legacy; never use in new code. The JDK's own Javadoc recommends Deque instead.
  • LinkedList implements Deque but pointer-chases and allocates a node object per element (GC pressure and cache misses).
  • ArrayDeque is a circular buffer over an array, power-of-two sized, with head/tail indices masked by & (len-1). It's the fastest stack and the fastest queue in the JDK. No capacity limit, no null elements allowed.
Deque<Integer> stack = new ArrayDeque<>();
stack.push(1); stack.push(2);          // addFirst
int top = stack.pop();                 // removeFirst -> 2

Deque<Integer> queue = new ArrayDeque<>();
queue.offer(1); queue.offer(2);        // addLast
int head = queue.poll();               // removeFirst -> 1

A Deque (pronounced "deck", short for double-ended queue) lets you add/remove at both ends. push/pop act on the head (stack behavior), and offer/poll add at the tail and remove from the head (queue behavior). Same object, both roles.

The real interview trap: why `null` is banned in `ArrayDeque`

ArrayDeque uses the value null itself as the empty-slot sentinel; a slot whose value is null counts as "empty". Also, poll/peek return null when the deque is empty to signal "nothing here". Now if you stored a real null, there'd be no way to tell whether the null you got back means "the stored value" or "the deque is empty" — ambiguous! So it's banned outright. LinkedList, by contrast, allows null because it distinguishes empty/non-empty via size.

Why power-of-two + masking

The head and tail wrap around a fixed array (reach the end, loop back to the start). Wrapping needs a remainder. But instead of index % length, a slow division, if the array length is a power of two you can use index & (length - 1) — just one bitwise AND, far faster.

Why AND works like magic

If length = 8 (a power of two), then length - 1 = 7, which in binary is 0111. The operation x & 0111 keeps exactly the low three bits of x and zeroes the rest — precisely the remainder of x divided by 8, but in one instruction instead of a division. The trick only works when the length is a power of two, which is why ArrayDeque doubles capacity on resize (not 1.5×), to preserve that invariant.

head = (head - 1) & (elements.length - 1);   // wrap on addFirst
tail = (tail + 1) & (elements.length - 1);    // wrap on addLast
// full when head == tail after an add -> double the array

Circular buffer (ring buffer) from scratch

Let's build a fixed-capacity ring ourselves. It's the workhorse of bounded queues, producer/consumer pipelines, and audio/network buffers.

A spinning carousel of wooden horses

Picture a carousel with a fixed number of horses. One pointer says "board from this horse" (head) and one says "the next empty horse to load is here" (tail). Reach the end and you loop back to the start. The classic puzzle: when head == tail, how do you know the carousel is completely full versus completely empty? The fix: keep a count.

public final class RingBuffer<E> {
    private final Object[] buf;
    private int head, tail, count;

    public RingBuffer(int capacity) { buf = new Object[capacity]; }

    public boolean offer(E e) {                 // O(1); false if full
        if (count == buf.length) return false;
        buf[tail] = e;
        tail = (tail + 1) % buf.length;
        count++;
        return true;
    }

    @SuppressWarnings("unchecked")
    public E poll() {                            // O(1); null if empty
        if (count == 0) return null;
        E e = (E) buf[head];
        buf[head] = null;                        // avoid loitering
        head = (head + 1) % buf.length;
        count--;
        return e;
    }
    public boolean isFull() { return count == buf.length; }
}

Notice we again do buf[head] = null to prevent loitering — same lesson as the dynamic array. I used % here because the capacity isn't necessarily a power of two; if you guarantee it is, you can upgrade to the faster & (buf.length - 1).

Hash tables

This is the heart of every large program's performance. A hash table maps keys to bucket indices and does get/put/remove in expected O(1) time.

The gym locker room

Picture a gym with 16 lockers. Instead of searching every locker each time, you have a rule: "locker number = your ID number modulo 16". Now to stash or grab anyone's things, you go straight to the right locker — no searching. That "rule" is the hash function, and "locker number" is the bucket index. In Java: index = hash(key) & (capacity - 1).

But there's a snag: what if two people's IDs give the same remainder? They both get sent to one locker. That's a collision. The two knobs that decide whether you actually get O(1) are the hash function (how well it spreads) and collision handling.

Collision handling: chaining vs open addressing

Separate chaining — each locker holds a list (or tree) of everyone who hashed there. Java's HashMap uses this. Simple, tolerant of high load factors, but each entry costs a node object and a pointer chase.

Open addressing — all entries live in the array itself; on collision you probe for the next free locker (linear, quadratic, or double hashing). With no separate node objects it's cache-friendly and compact, but it degrades sharply as load factor approaches 1, and deletion needs tombstones.

Why deletion in open addressing needs "tombstones"

Suppose A, B, C all hashed to locker 5, and since it was full they went to lockers 6 and 7 in turn (linear probing). Now if you delete B and simply null locker 6, then later when you search for C you start at locker 5, reach locker 6, see it empty, and conclude "C doesn't exist" — but C is in locker 7! The probe chain broke. The fix: instead of null, leave a special "something used to be here" marker — a tombstone — so probing continues past it. ThreadLocalMap and IdentityHashMap use open addressing.

Chaining Open addressing
Memory higher (nodes) lower (flat array)
Cache worse better
Load factor tolerance up to >1 must stay < ~0.7
Deletion trivial needs tombstones
Clustering none primary/secondary

("Clustering" is when several consecutive slots fill up and later probes are forced to skim over all of them — a sticky run that slows things down.)

Load factor and resizing

Load factor = entries ÷ buckets. How crowded the lockers are. Java's default is 0.75: a balance between space (too low wastes empty lockers) and time (too high means long chains and slowness). When size > capacity × 0.75, HashMap resizes to double capacity and rehashes every entry. Default initial capacity is 16.

If you know the count, pre-size

Every resize is a full O(n) pass over all entries — a silent tax. If you know you'll insert N entries, start with enough capacity:

new HashMap<>((int)(N / 0.75) + 1)

That way you never hit the resize threshold and skip all those repeated rehashes.

Java HashMap internals (Java 8+)

  • Buckets are a Node[]. A bucket is a singly linked list until it gets long.
  • Treeification: when a single bucket reaches TREEIFY_THRESHOLD = 8 entries and total capacity ≥ MIN_TREEIFY_CAPACITY = 64, that bucket converts to a red-black tree, turning worst-case bucket lookup from O(n) to O(log n). Below capacity 64 it resizes first instead of treeifying. It untreeifies back to a list at UNTREEIFY_THRESHOLD = 6.
  • HashMap spreads the hash: h = key.hashCode(); h ^= (h >>> 16);.
Hysteresis: why treeify at 8 but untreeify at 6

Why convert to a tree at 8 but revert to a list at 6, not both at 7? Think of an AC thermostat: if turning on and off both trigger at exactly 25°, the AC flickers on/off around that temperature. So you leave a gap: on at 26, off at 24. That gap is called hysteresis, and it prevents useless oscillation around the boundary. The 8-and-6 thresholds in HashMap are exactly that.

Why `h ^ (h >>> 16)` — rescuing the high bits

The bucket index is hash & (capacity - 1), which for small capacities only looks at the low bits of the hash and discards the high bits. Now suppose a hashCode varies mainly in its high bits while its low bits look alike — then all keys collide into one bucket! The trick h ^ (h >>> 16) XORs the high bits down into the low bits, so that entropy (variety) is preserved in the masked index. That's why a bad hashCode (e.g. always a constant) collapses the whole map into a single tree/list — O(log n) at best, and O(n) with a non-Comparable key.

The `equals`/`hashCode` contract — a classic production bug

Two objects that are equals must have the same hashCode. Violate it and a key you put becomes unfindable, because get hashes it to a different bucket. Worse: if you mutate a field used in hashCode after insertion, the entry gets stranded in the wrong bucket and is lost. Practical lesson: make map keys immutable on their hashCode/equals fields.

A minimal chaining hash map from scratch

public final class MyHashMap<K, V> {
    private static final class Node<K, V> {
        final int hash; final K key; V val; Node<K, V> next;
        Node(int h, K k, V v, Node<K, V> n) { hash = h; key = k; val = v; next = n; }
    }
    private Node<K, V>[] table;
    private int size;
    private static final float LOAD_FACTOR = 0.75f;

    @SuppressWarnings("unchecked")
    public MyHashMap() { table = new Node[16]; }

    private static int spread(Object k) {          // mimic HashMap's hash spread
        int h = (k == null) ? 0 : k.hashCode();
        return h ^ (h >>> 16);
    }
    private int indexFor(int hash) { return hash & (table.length - 1); }

    public V put(K key, V val) {
        int h = spread(key), i = indexFor(h);
        for (Node<K, V> e = table[i]; e != null; e = e.next) {
            if (e.hash == h && Objects.equals(e.key, key)) {  // hash short-circuit before equals
                V old = e.val; e.val = val; return old;
            }
        }
        table[i] = new Node<>(h, key, val, table[i]);         // prepend
        if (++size > table.length * LOAD_FACTOR) resize();
        return null;
    }

    public V get(K key) {
        int h = spread(key);
        for (Node<K, V> e = table[indexFor(h)]; e != null; e = e.next)
            if (e.hash == h && Objects.equals(e.key, key)) return e.val;
        return null;
    }

    @SuppressWarnings("unchecked")
    private void resize() {
        Node<K, V>[] old = table;
        table = new Node[old.length << 1];                    // double -> stay power of two
        for (Node<K, V> head : old)
            for (Node<K, V> e = head; e != null; ) {
                Node<K, V> next = e.next;
                int i = indexFor(e.hash);                     // recompute with new length
                e.next = table[i]; table[i] = e;              // re-prepend
                e = next;
            }
    }
}
Why `e.hash == h` before `equals`

Notice the e.hash == h check before equals. Comparing two cached integers is nearly free, but equals can be expensive (e.g. comparing two long strings character by character). With this order, when the hashes differ we never even enter equals — a cheap short-circuit that's the first line of defense against costly comparisons on collision.

Classic problems

Now, with those same substrates, we build the problems you'll see in every senior interview.

Detect a cycle — Floyd's tortoise & hare

Two runners on a track

Put two runners on a track: one slow (one step at a time), one fast (two steps). If the track is an open path, the fast runner reaches the end first, done. But if the track is a closed loop, the fast runner keeps lapping and eventually catches the slow one from behind — they collide. So "collision = a cycle exists". This simple idea detects a cycle in O(1) space.

Two pointers, one moving 1 step, one 2 steps. If there's a cycle they eventually meet; if not, the fast pointer hits null. O(n) time, O(1) space (beating the O(n)-space hash-set approach).

static <E> boolean hasCycle(Node<E> head) {
    Node<E> slow = head, fast = head;
    while (fast != null && fast.next != null) {
        slow = slow.next;              // +1
        fast = fast.next.next;         // +2
        if (slow == fast) return true; // pointers collided => cycle
    }
    return false;
}

Finding the cycle's start (Floyd part 2): after they meet, reset one pointer to head and advance both by 1; they meet at the entry. I give the full proof in the interview section (the head-to-entry distance equals the meeting-point-to-entry distance, mod cycle length).

Valid parentheses — stack

Nested plates

Each opening bracket is like setting a plate on the pile. Each closing bracket must match the top plate and remove it. If the plate you remove is the wrong kind (e.g. closing ( with ]), it's invalid. And if a plate is still on the pile at the end, an opener was left unclosed — invalid too. That's exactly a stack's job.

static boolean isValid(String s) {
    Deque<Character> st = new ArrayDeque<>();
    Map<Character, Character> pair = Map.of(')', '(', ']', '[', '}', '{');
    for (char c : s.toCharArray()) {
        if (pair.containsValue(c)) st.push(c);           // opener
        else if (pair.containsKey(c)) {                  // closer
            if (st.isEmpty() || st.pop() != pair.get(c)) return false;
        }
    }
    return st.isEmpty();                                 // leftover openers = invalid
}

O(n) time, O(n) space. The two failure modes: a closer that doesn't match the top, and leftover openers at the end. Missing the final isEmpty() check is the most common bug — try the code on input "(" and see.

Min-stack — O(1) getMin

A "min so far" sticker on every plate

You want to always know the smallest number in the stack, in O(1). The trick: stick a label on each plate saying "the min of everything from this plate down". When you pop a plate, its label goes too, and the plate beneath again shows the correct min — for free. To do this you keep a second parallel stack storing only the mins.

class MinStack {
    private final Deque<Integer> data = new ArrayDeque<>();
    private final Deque<Integer> mins = new ArrayDeque<>();
    public void push(int x) {
        data.push(x);
        mins.push(mins.isEmpty() ? x : Math.min(x, mins.peek()));
    }
    public void pop()      { data.pop(); mins.pop(); }
    public int  top()      { return data.peek(); }
    public int  getMin()   { return mins.peek(); }   // O(1)
}

A space-optimized variant stores the min only when it changes and handles duplicates carefully — mention it, but the two-stack version is bug-free and interview-safe.

LRU cache — hash map + doubly linked list

A limited desk

LRU stands for Least Recently Used. Like a small desk that fits only a few files. Any file you just touched goes on top of the pile (head). When the desk is full and a new file arrives, you discard the one you haven't touched for the longest — the bottom of the pile (tail). To make everything O(1), you combine two tools: a HashMap for "where is it?", and a doubly linked list for "recency order".

The canonical composition: a HashMap for O(1) lookup, a doubly linked list for O(1) recency reordering and eviction. Most recently used at the head, least recently used at the tail; evict the tail on overflow.

class LRUCache {
    private static final class Node { int key, val; Node prev, next; Node(int k,int v){key=k;val=v;} }
    private final int cap;
    private final Map<Integer, Node> map = new HashMap<>();
    private final Node head = new Node(0,0), tail = new Node(0,0);   // sentinels

    LRUCache(int capacity) { cap = capacity; head.next = tail; tail.prev = head; }

    private void remove(Node n){ n.prev.next = n.next; n.next.prev = n.prev; }
    private void addFront(Node n){ n.next = head.next; n.prev = head; head.next.prev = n; head.next = n; }

    public int get(int key) {
        Node n = map.get(key);
        if (n == null) return -1;
        remove(n); addFront(n);                 // touch => move to front
        return n.val;
    }
    public void put(int key, int value) {
        Node n = map.get(key);
        if (n != null) { n.val = value; remove(n); addFront(n); return; }
        if (map.size() == cap) {                // evict LRU (tail)
            Node lru = tail.prev;
            remove(lru); map.remove(lru.key);
        }
        Node fresh = new Node(key, value);
        addFront(fresh); map.put(key, fresh);
    }
}
The sentinel-node trick

The two sentinel nodes (head and tail), which hold no data and are just the two ends of the list, eliminate every null check. Because every real node always has a non-null prev and next, the remove/addFront methods need no special cases for the "first" or "last" element. That trick is what makes the code clean and bug-free, and interviewers watch for it. In production, LinkedHashMap with accessOrder=true and an overridden removeEldestEntry gives you an LRU in ~5 lines — know both the from-scratch and the library answer.

Best practices

  • Default to ArrayList and ArrayDeque. Reach for LinkedList only when you provably need O(1) node splicing with a held reference.
  • Pre-size collections when the count is known: new ArrayList<>(n), new HashMap<>((int)(n/0.75)+1). Resizing is the silent O(n) tax.
  • Never use java.util.Stack or Vector in new code — synchronized legacy classes.
  • Always implement hashCode and equals together, from the same fields, and make map keys immutable on those fields.
  • Null out slots on removal in array-backed structures to prevent loitering leaks.
  • For concurrency, don't wrap — use ConcurrentHashMap, ConcurrentLinkedQueue, ArrayBlockingQueue, not Collections.synchronizedMap.

Interview Questions

1. Why is `ArrayList.add` O(1) amortized but not worst-case O(1)?

Growth by a constant factor (1.5×) makes total copy work across n appends a geometric series summing to ~3n, i.e. O(1) per append averaged. But any single append that triggers a resize copies all n elements — that one is O(n). Amortized (the average) differs from worst-case; here the average is constant but a single operation can be linear.

2. (Hard) Why did Java pick growth factor 1.5 instead of 2?

Less wasted memory, and crucially, with factor ≥ 2 the newly requested block is always bigger than the sum of all previously freed blocks, so the allocator can never reuse freed memory. Factor < 2 eventually allows reuse, reducing fragmentation.

3. `ArrayList` vs `LinkedList` for a queue with lots of head removals — and does the textbook answer hold?

Textbook: LinkedList (O(1) head removal) beats ArrayList (O(n) head shift). Real answer: use neither — use ArrayDeque, a circular buffer with O(1) at both ends and cache-friendly contiguous memory that beats LinkedList in practice despite equal Big-O, because pointer chasing causes cache misses.

4. Why can't you store `null` in an `ArrayDeque`?

It uses null as the empty-slot sentinel and poll/peek return null to mean "empty". A stored null would be indistinguishable from empty. LinkedList allows nulls because it distinguishes via size.

5. Walk me through what happens on `HashMap.put` when a bucket gets long.

Entries chain in a singly linked list. When one bucket hits 8 entries and table capacity ≥ 64, it treeifies to a red-black tree (bucket lookup O(n)→O(log n)). Below capacity 64 it resizes instead. It untreeifies back to a list at 6 entries. Load factor 0.75 triggers a capacity-doubling resize when size exceeds cap×0.75.

6. (Hard) Why does `HashMap` XOR the high bits of the hash into the low bits (`h ^ (h >>> 16)`)?

The bucket index is hash & (capacity-1), which for small capacities only uses the low bits of the hash. If a hashCode varies mainly in its high bits, all keys would collide into one bucket. Spreading high bits down preserves that entropy in the masked index.

7. Chaining vs open addressing — when would you choose open addressing?

When memory and cache locality matter and load factor stays well below ~0.7: open addressing stores entries inline (no node objects), so it's cache-friendly and compact. Downsides: it degrades sharply near full, suffers clustering, and deletion needs tombstones. Chaining tolerates high load factors and trivial deletes but pays per-node memory and pointer chasing. Java's HashMap chains; ThreadLocalMap/IdentityHashMap open-address.

8. (Gotcha) A colleague mutates a field used in a map key's `hashCode` after `put`. What breaks?

get now hashes to a different bucket than where the entry lives, so the key becomes unfindable even though it's still in the map — a silent leak/lost-entry. Map keys must be effectively immutable on their hashCode/equals fields. Same class of bug: violating the equals↔hashCode contract makes equal keys land in different buckets.

9. Prove Floyd's cycle-start algorithm.

Let head-to-entry distance be a, entry-to-meeting b, and remaining cycle c (so cycle length = b+c). When they meet, slow traveled a+b, fast traveled a+b+k(b+c) and equals 2(a+b). Solving gives a = c + (k-1)(b+c), i.e. a ≡ c (mod cycle length). So a pointer from head and a pointer from the meeting point, each stepping by 1, meet exactly at the entry.

10. (Find the bug) This `isValid` returns `true` for `"("`. Why?

If the code returns without a final st.isEmpty() check — only verifying each closer matches — leftover openers pass. The fix is return st.isEmpty() at the end. Symmetric bug: not checking st.isEmpty() before pop() on a closer crashes/false-negatives on ")".

11. (What prints?)
Map<Integer,String> m = new HashMap<>();
m.put(1,"a"); m.put(1,"b");
System.out.println(m.size() + " " + m.get(1));

1 b. put with an existing key overwrites the value and returns the old one; size stays 1. Contrast with a Multimap or computeIfAbsent.

12. Why do LRU implementations use a doubly (not singly) linked list, and what are the sentinel nodes for?

On a cache hit you must unlink a node from the middle in O(1), which requires the prev pointer — singly linked would be O(n) to find the predecessor. The head/tail sentinels remove all null-checks: every real node always has a non-null prev and next, so remove/addFront need no edge cases for the first/last element.

13. (Hard) You need a fixed-size, thread-safe producer/consumer buffer. What do you reach for and why not a raw ring buffer?

ArrayBlockingQueue — a bounded circular buffer with a single lock and two Conditions (notFull, notEmpty) providing blocking put/take. Rolling your own ring buffer misses the hard part: correct blocking/wakeup semantics and memory visibility. For higher throughput, a lock-free ring (LMAX Disruptor) or ConcurrentLinkedQueue (unbounded, Michael-Scott based).

14. What's the difference between `Deque.push/pop` and `offer/poll` semantics, and why does it matter?

push/pop operate on the head (stack, LIFO); offer/poll add at tail and remove from head (queue, FIFO). Same ArrayDeque object serves both — the method you call defines the discipline. Mixing them (e.g. push then poll) silently gives FIFO-of-a-stack behavior; a common source of "my stack acts like a queue" bugs.

15. (Hard) How does treeification interact with a `hashCode` that returns a constant, for keys that don't implement `Comparable`?

All keys collide into one bucket, which treeifies. But the red-black tree needs an ordering; for non-Comparable keys HashMap falls back to comparing System.identityHashCode (a "tie-breaker" order) which is arbitrary but consistent — so lookups become O(log n) rather than O(n), but you lose meaningful ordering and gain nothing else. The real fix is a proper hashCode; treeification is damage control, not a substitute.

In a nutshell
  • Everything is just two substrates: contiguous memory (arrays; O(1) random access, O(n) middle insert) and linked nodes (O(1) insert/remove with a ref, O(n) access). The cache row decides the real-world winner.
  • Dynamic arrays grow by 1.5× making add amortized O(1) (a ~3n geometric series). On removal, null the slot to avoid loitering.
  • Linked lists: reversal = the three-pointer dance; doubly = O(1) removal when you hold the node.
  • Stacks and queues are just policies; always prefer ArrayDeque (circular buffer, power-of-two + mask, no null) over Stack/LinkedList.
  • Hash tables: index = hash & (cap-1), load factor 0.75, treeify at 8 and revert at 6 (hysteresis), hash spread h ^ (h >>> 16), and the sacred equals↔hashCode contract.
  • Classic problems: Floyd (O(1) space), valid parentheses (the final isEmpty check!), the two-stack min-stack, and LRU = HashMap + doubly linked list with sentinel nodes. Feel these, and no linear structure can surprise you in an interview.