שק של ערכים ייחודיים וממוינים
ראיתם איך unordered_map שומר זוגות מפתח-ערך עם חיפוש מהיר מבוסס גיבוב. std::set הוא בן הדוד הפשוט יותר: הוא שומר רק ערכים, בלי נתונים משויכים, ואוכף שני כללים אוטומטית. כל איבר הוא ייחודי (כפילויות נזרקות בשקט), והאיברים תמיד נשמרים בסדר ממוין.
זה הופך את set לבחירה הטבעית לשאלות כמו "האם כבר ראיתי את זה?" או "תן לי את הפריטים השונים בסדר ממוין". מכניסים בלי לדאוג לכפילויות, ועוברים על האיברים בלי למיין קודם.
שימו לב שהכנסנו את 10 פעמיים ולא לפי הסדר, ובכל זאת הפלט ממוין ו-10 מופיע פעם אחת. ה-set עשה בשבילכם את הניהול.
הכנסה והסרה
insert מוסיף ערך אם הוא עוד לא שם. הוא מחזיר pair שה-.second שלו הוא bool שאומר אם ההכנסה באמת קרתה, וזה שימושי כשרוצים לדעת אם ערך היה חדש:
כדי להסיר ערך, קוראים ל-erase עם הערך עצמו: הוא מחזיר את מספר האיברים שהוסרו (0 או 1 ב-set). מחיקה של משהו שלא קיים לא מזיקה ואינה שגיאה:
בדיקת שייכות
כל הטעם ב-set הוא בדיקות מהירות של "האם זה נמצא כאן?". הדרך הברורה ביותר היא count, שמחזיר 1 או 0:
מאז C++20 יש אפשרות קריאה עוד יותר, contains, שמחזירה bool ישירות:
if (primes.contains(7)) { /* ... */ } // C++20
טעות נפוצה היא לנסות להשתמש ב-operator[] כמו ב-map. ל-set אין operator[]: אין ערך להביא, יש רק נוכחות לבדוק. השתמשו ב-count או ב-contains, לא ב-s[7].
אם צריך את המיקום בפועל (כדי למחוק אותו, או כדי להסתכל על השכנים), השתמשו ב-find, שמחזיר איטרטור או end():
מעבר מסודר ושאילתות טווח
מכיוון ש-set ממוין, מעבר עליו תמיד מחזיר את האיברים מהקטן לגדול, ומקבלים בחינם את הטריקים של מכולה מסודרת. lower_bound(x) נותן את האיבר הראשון שאינו קטן מ-x, ו-upper_bound(x) את האיבר הראשון שגדול ממש מ-x: יחד הם מאפשרים לסרוק טווח מספרי בלי לבדוק כל איבר:
כלל עדין אבל חשוב: איברי set אינם ניתנים לשינוי. האיטרטור נותן לכם הפניית const, ולכן אי אפשר לשנות איבר במקומו: זה עלול לשבור את סדר המיון שהמכולה מסתמכת עליו. כדי "לשנות" ערך, מחקו את הישן והכניסו את החדש.
כברירת מחדל הסדר עולה (std::less). לסדר יורד, ספקו פונקציית השוואה אחרת כארגומנט התבנית השני:
set מול multiset מול unordered_set
std::set הוא אחד משלושה קרובי משפחה, וחשוב לבחור את הנכון:
set<int> // ערכים ייחודיים, ממוין, O(log n)
multiset<int> // מאפשר כפילויות, ממוין, O(log n)
unordered_set<int> // ערכים ייחודיים, בלי סדר, O(1) בממוצע
השתמשו ב-unordered_set כשצריך רק בדיקות שייכות והסדר לא משנה: החיפושים מבוססי הגיבוב שלו מהירים יותר בממוצע מה-O(log n) מבוסס העץ של set. בחרו ב-set כשצריך איברים בסדר ממוין, שאילתות טווח עם lower_bound/upper_bound, או התנהגות יציבה של איטרטורים. השתמשו ב-multiset רק כשלכפילויות יש משמעות (למשל היסטוגרמה של ערכים חוזרים): ב-multiset, count(x) יכול להחזיר יותר מ-1, ו-erase(x) מסיר את כל העותקים, אלא אם מוחקים לפי איטרטור בודד.
שימוש קלאסי אחד ב-set: הסרת כפילויות ומיון של vector בצעד אחד.
בניית ה-set מהאיטרטורים של ה-vector זורקת כל כפילות וממיינת את השאר: בלי לולאה ידנית, ובלי הריקוד של מיון מפורש ועוד std::unique.
הבא בתור: pair ו-tuple
ראיתם עכשיו את .first ו-.second מופיעים ב-pair ש-insert מחזיר, ו-structured bindings יחד עם מילת המפתח auto (auto [it, inserted]) מפרקים אותו בצורה נקייה. הטיפוסים הקלים האלה, ש"אורזים כמה ערכים יחד", נמצאים בכל מקום ב-STL. בהמשך נסתכל ישירות על pair ו-tuple: איך בונים אותם, איך מפרקים אותם, ואיך מחזירים כמה ערכים מפונקציה בלי להגדיר struct שלם.
שאלות נפוצות
מהו set ב-C++?
std::set היא מכולה אסוציאטיבית ששומרת ערכים ייחודיים בסדר ממוין. הכנסה של ערך שכבר קיים לא עושה כלום, ומעבר על האיברים הולך מהקטן לגדול. חיפוש, הכנסה ומחיקה הם כולם O(log n), כי היא ממומשת כעץ חיפוש בינארי מאוזן.
איך בודקים אם איבר קיים ב-set של C++?
השתמשו ב-s.count(x), שמחזיר 1 אם x קיים ו-0 אם לא, או ב-s.contains(x) ב-C++20, שמחזיר bool. הימנעו מ-s.find(x) != s.end() אלא אם באמת צריך את האיטרטור: העלות זהה אבל זה ארוך יותר.
מה ההבדל בין set ל-unordered_set ב-C++?
std::set שומר את האיברים ממוינים ומציע פעולות O(log n), ו-std::unordered_set שומר אותם בלי סדר מסוים בעזרת טבלת גיבוב, עם פעולות O(1) בממוצע. השתמשו ב-set כשצריך מעבר מסודר או שאילתות טווח, וב-unordered_set כשצריך רק בדיקות שייכות מהירות והסדר לא משנה.