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 حفظ کنی.
بیا با هم قدمبهقدم برویم. هیچ اصطلاحی را بدون توضیح رها نمیکنم.
اینجا اینها را یاد میگیری:
- دو زیربنای فیزیکی — حافظهٔ پیوسته (آرایه) در برابر گرههای پیوندی (اشارهگر)، و مدل هزینهٔ هرکدام.
- آرایهٔ پویا — چرا
ArrayListجادویی به نظر میرسد و چراaddسرشکن O(1) است. - لیست پیوندی — یکطرفه و دوطرفه، و هنر معکوسسازی و جدا کردن گره.
- پشته و صف — چرا
ArrayDequeبهتر ازStackوLinkedListاست، و بافر حلقوی از صفر. - جدول هش — برخورد، ضریب بار، درختسازی، و یک
HashMapدستساز. - مسائل کلاسیک — فلوید، پرانتز معتبر، min-stack و LRU.
- پرسشهای مصاحبه — ۱۵ سؤال واقعی با پاسخ کامل.
بخش ۰ — واژههایی که باید بشناسی
قبل از شروع، چند واژه را با هم باز میکنیم تا بعداً بدون مکث جلو برویم.
دو راه برای نگهداری ترتیبیِ چیزها تصور کن. راه اول: یک قفسهٔ بلندِ کتاب با خانههای شمارهدار و کنارِهم. برای رسیدن به کتاب شمارهٔ ۵۰ فقط کافی است مستقیم بروی سراغ خانهٔ ۵۰. اما اگر بخواهی کتابی را وسط قفسه جا بدهی، باید همهٔ کتابهای بعدی را یکییکی هُل بدهی. راه دوم: یک زنجیره از آدمها که هرکدام دستِ نفر بعدی را گرفته. برای رسیدن به نفر پنجاهم باید از اول زنجیره یکییکی جلو بروی. اما اگر بخواهی کسی را وسط زنجیره اضافه کنی، فقط دو تا دست را باز و بسته میکنی. قفسه = آرایه؛ زنجیره = لیست پیوندی. کل این درس تنشِ بین همین دو است.
- حافظهٔ پیوسته (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) هم اثر دارد. با ضریب ۲، بلوکِ جدید همیشه بزرگتر از مجموعِ همهٔ بلوکهای آزادشدهٔ قبلی است، پس تخصیصدهنده هرگز نمیتواند فضای آزادشده را دوباره استفاده کند و حافظه سوراخسوراخ میماند. ضریب کمتر از ۲ (مثل ۱٫۵ که نزدیک نسبت طلایی است) در نهایت اجازهٔ استفادهٔ مجدد را میدهد. این یک دلیل واقعیِ انتخاب ۱٫۵ توسط جاوا است.
پیادهسازی از صفر
حالا خودمان یکی بسازیم. به کامنتها دقت کن — هر خط یک درس است:
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).
این خط ظریفترین و سنیورترین جزئیات کد است. وقتی عنصری را حذف میکنی، اگر آن خانه را 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(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 از دنباله اضافه و از سر حذف میکنند (رفتار صف). همان یک شیء، هر دو نقش.
ArrayDeque از خودِ مقدار null بهعنوان نگهبانِ خانهٔ خالی (sentinel) استفاده میکند؛ یعنی خانهای که مقدارش null است، «خالی» تلقی میشود. از طرفی poll/peek وقتی صف تهی است null برمیگردانند تا بگویند «چیزی نیست». حالا اگر تو خودت یک null واقعی ذخیره کنی، دیگر معلوم نیست nullی که گرفتی یعنی «مقدارِ ذخیرهشده» یا «صف خالی است» — ابهام! برای همین کلاً ممنوعش کردهاند. در مقابل، LinkedList مقدار null را میپذیرد چون خالی/پُر را از طریق size تشخیص میدهد.
چرا توانی از دو + ماسک
head و tail دور یک آرایهٔ ثابت میپیچند (وقتی به ته آرایه رسیدی، برمیگردی سرِ خط). برای این پیچش باید باقیماندهٔ تقسیم بگیری. اما بهجای index % length که یک تقسیمِ کند است، اگر طول آرایه توانی از دو باشد میتوانی از index & (length - 1) استفاده کنی — فقط یک عملیاتِ 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 (پسماند) میگویند و از نوسانِ بیفایده دورِ مرز جلوگیری میکند. آستانههای ۸ و ۶ در HashMap دقیقاً همیناند.
اندیسِ سطل hash & (capacity - 1) است که برای ظرفیتهای کوچک فقط بیتهای پایینِ هش را نگاه میکند و بیتهای بالا را دور میریزد. حالا فرض کن یک hashCode عمدتاً در بیتهای بالا تفاوت دارد و بیتهای پایینش شبیهِ هماند — آنگاه همهٔ کلیدها در یک سطل برخورد میکنند! ترفند h ^ (h >>> 16) بیتهای بالا را با XOR میریزد داخل بیتهای پایین، پس آن آنتروپی (تنوع) در اندیسِ ماسکشده حفظ میشود. به همین دلیل یک hashCode بد (مثلاً همیشه یک مقدار ثابت) کل نقشه را به یک تک درخت/لیست فرو میریزد — در بهترین حالت O(log n)، و با کلیدِ غیرِ Comparable برابرِ O(n).
دو شیءِ برابر (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 دقت کن. مقایسهٔ دو عددِ صحیحِ کششده تقریباً رایگان است، اما 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);
}
}
دو گرهِ نگهبان (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.
پرسشهای مصاحبه
رشد با ضریب ثابت (۱٫۵×) کل کارِ کپی در طول n افزودن را به یک سری هندسی با مجموع حدود 3n تبدیل میکند، یعنی بهطور میانگین O(1) بهازای هر افزودن. اما هر افزودنِ منفردی که resize را تحریک کند همهٔ n عنصر را کپی میکند — آن یکی O(n) است. سرشکن (میانگین) با بدترینحالت فرق دارد؛ اینجا میانگین ثابت است ولی یک عملِ منفرد میتواند خطی باشد.
حافظهٔ هدررفتهٔ کمتر، و مهمتر اینکه با ضریب ≥ ۲، بلوکِ تازه درخواستشده همیشه بزرگتر از مجموعِ همهٔ بلوکهای آزادشدهٔ قبلی است، پس تخصیصدهنده هرگز نمیتواند حافظهٔ آزاد را دوباره استفاده کند. ضریبِ کمتر از ۲ سرانجام اجازهٔ استفادهٔ مجدد را میدهد و قطعهقطعهشدگی (fragmentation) را کم میکند.
کتابی: LinkedList (حذفِ سر O(1)) از ArrayList (شیفتِ سر O(n)) بهتر است. پاسخِ واقعی: هیچکدام — از ArrayDeque استفاده کن، یک بافرِ حلقوی با O(1) در هر دو سر و حافظهٔ پیوستهٔ سازگار با cache که در عمل با وجودِ Big-O برابر از LinkedList بهتر است، چون تعقیبِ اشارهگر cache miss تولید میکند.
از null بهعنوان نگهبانِ خانهٔ خالی (sentinel) استفاده میکند و poll/peek مقدار null را بهمعنای «خالی» برمیگردانند. یک nullِ ذخیرهشده از «خالی» قابل تشخیص نخواهد بود. LinkedList مقدار null را میپذیرد چون از طریق size تمایز میگذارد.
درایهها در یک لیست پیوندیِ یکطرفه زنجیر میشوند. وقتی یک سطل به ۸ درایه میرسد و ظرفیت جدول ≥ ۶۴ است، به درخت قرمز-سیاه تبدیل میشود (جستوجوی سطل از O(n) به O(log n)). زیرِ ظرفیت ۶۴، بهجای آن resize میکند. در ۶ درایه دوباره به لیست untreeify میشود. ضریب بار ۰٫۷۵ وقتی size از cap×۰٫۷۵ فراتر رود، resizeِ دوبرابریِ ظرفیت را تحریک میکند.
اندیسِ سطل hash & (capacity-1) است که برای ظرفیتهای کوچک فقط بیتهای پایینِ هش را استفاده میکند. اگر یک hashCode عمدتاً در بیتهای بالا تغییر کند، همهٔ کلیدها در یک سطل برخورد میکنند. پخشِ بیتهای بالا به پایین، آن آنتروپی را در اندیسِ ماسکشده حفظ میکند.
وقتی حافظه و محلیتِ cache مهم است و ضریب بار خیلی زیر ~۰٫۷ میماند: آدرسدهی باز درایهها را درجا (بدون شیءِ گره) ذخیره میکند، پس سازگار با cache و فشرده است. معایب: نزدیک پُر شدن بهشدت افت میکند، از خوشهبندی رنج میبرد و حذف نیازمند tombstone است. زنجیرهسازی ضرایبِ بارِ بالا و حذفِ ساده را تحمل میکند اما هزینهٔ حافظهٔ گره و تعقیبِ اشارهگر میپردازد. HashMap جاوا زنجیرهای است؛ ThreadLocalMap/IdentityHashMap آدرسدهی باز.
اکنون 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 و یک اشارهگر از نقطهٔ برخورد، هر یک با گام ۱، دقیقاً در ورودی به هم میرسند.
اگر کد بدونِ بررسیِ نهاییِ 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.
هنگام برخورد در cache باید یک گره را از وسط در O(1) جدا کنی که نیازمندِ اشارهگرِ prev است — یکطرفه برای یافتنِ پیشین O(n) میشد. نگهبانهای head/tail همهٔ بررسیهای null را حذف میکنند: هر گرهِ واقعی همیشه prev و nextِ غیرِ null دارد، پس remove/addFront نیازی به حالتِ خاص برای عنصر اول/آخر ندارند.
ArrayBlockingQueue — یک بافرِ حلقویِ کراندار با یک قفل و دو Condition (notFull، notEmpty) که put/takeِ مسدودکننده فراهم میکند. ساختنِ ring bufferِ دستیِ خودت بخشِ سخت را از قلم میاندازد: معناشناسیِ صحیحِ مسدودسازی/بیدارسازی و visibility حافظه. برای throughputِ بالاتر، یک حلقهٔ بدونِ قفل (LMAX Disruptor) یا ConcurrentLinkedQueue (نامحدود، مبتنی بر Michael-Scott).
push/pop روی سر عمل میکنند (پشته، LIFO)؛ offer/poll به دنباله میافزایند و از سر حذف میکنند (صف، FIFO). یک شیءِ ArrayDeque واحد به هر دو خدمت میکند — متدی که فراخوانی میکنی نظم را تعیین میکند. مخلوط کردنشان (مثلاً push سپس poll) بهخاموشی رفتارِ FIFO-روی-پشته میدهد؛ منبعِ رایجِ باگِ «پشتهٔ من مثلِ صف رفتار میکند».
همهٔ کلیدها در یک سطل برخورد میکنند که درختسازی میشود. اما درخت قرمز-سیاه به یک ترتیب نیاز دارد؛ برای کلیدهای غیرِ 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.
Here's what you'll learn:
- The two physical substrates — contiguous memory (arrays) vs linked nodes (pointers), and each one's cost model.
- Dynamic arrays — why
ArrayListfeels magic and whyaddis amortized O(1). - Linked lists — singly and doubly, plus the art of reversal and node unlinking.
- Stacks and queues — why
ArrayDequebeatsStackandLinkedList, and a ring buffer from scratch. - Hash tables — collisions, load factor, treeification, and a hand-built
HashMap. - Classic problems — Floyd, valid parentheses, min-stack, LRU.
- 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.
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).
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?
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.
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).
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).
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;
}
}
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
}
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.
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.StackextendsVector— 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 recommendsDequeinstead.LinkedListimplementsDequebut pointer-chases and allocates a node object per element (GC pressure and cache misses).ArrayDequeis 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, nonullelements 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.
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.
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.
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.
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.
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.
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 = 8entries 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 atUNTREEIFY_THRESHOLD = 6. HashMapspreads the hash:h = key.hashCode(); h ^= (h >>> 16);.
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.
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.
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;
}
}
}
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
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
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
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
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 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
ArrayListandArrayDeque. Reach forLinkedListonly 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.StackorVectorin new code — synchronized legacy classes. - Always implement
hashCodeandequalstogether, 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, notCollections.synchronizedMap.
Interview Questions
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 ")".
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.
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.
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).
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.
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.
- 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
addamortized 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) overStack/LinkedList. - Hash tables:
index = hash & (cap-1), load factor 0.75, treeify at 8 and revert at 6 (hysteresis), hash spreadh ^ (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.