Menu

unordered_map ב-C++: חיפוש מהיר בטבלת hash

למדו std::unordered_map ב-C++: האח של map שמבוסס על טבלת hash ונותן הכנסה וחיפוש ב-O(1) בממוצע. כולל פעולות בסיס, מלכודת ההכנסה האוטומטית של [], count מול find, ומתי לבחור בו במקום map ממוין.

בדף הזה יש עורכים שאפשר להריץ - לערוך, להריץ ולראות את הפלט מיד.

כשהסדר לא משנה

זה עתה ראיתם את std::map, ששומר את המפתחות שלו ממוינים בעזרת עץ מאוזן ונותן פעולות ב-O(log n). אבל למיון יש מחיר, והרבה פעמים לא אכפת לכם באיזה סדר המפתחות יוצאים: אתם רק רוצים לשאול "האם המפתח הזה כאן, ומה הערך שלו?" כמה שיותר מהר.

בשביל זה יש את std::unordered_map. זו טבלת hash: היא מעבירה כל מפתח דרך פונקציית hash כדי להחליט איפה לאחסן אותו, וכך הכנסה, חיפוש ומחיקה הם O(1) בממוצע במקום O(log n). המחיר הוא שסדר המעבר לא מוגדר: המפתחות יוצאים בסדר שבו ה-buckets במקרה מחזיקים אותם.

הממשק כמעט זהה לזה של map, בכוונה: לעיתים קרובות אפשר להחליף אחד בשני רק על ידי שינוי הטיפוס. כללו את <unordered_map> (לא את <map>), והמפתחות יוצאים לא ממוינים.

הכנסה ועדכון

יש כמה דרכים להכניס רשומות, והן לא כולן מתנהגות אותו דבר. השתיים שתשתמשו בהן הכי הרבה הן operator[] ו-insert:

ההבדל המרכזי: operator[] דורס ערך קיים, ו-insert משאיר מפתח קיים בלי שינוי. אם אתם ב-C++17, insert_or_assign(key, value) נותן סמנטיקה של "קבע את זה בכל מקרה", ו-try_emplace(key, args...) בונה את הערך במקום רק כשהמפתח חדש: שימושי לערכים שיקר לבנות.

המלכודת של operator[]: הוא מכניס בקריאה

זה הבאג הנפוץ ביותר ב-unordered_map, ולכן הוא מקבל סעיף משלו. m[key] הוא לא קריאה טהורה. אם המפתח חסר, הוא יוצר ערך ברירת מחדל ומכניס אותו (int הופך ל-0, string הופך ל-""), ואז מחזיר הפניה. כך קוד שנראה כמו חיפוש מגדיל את ה-map בשקט:

seen["y"] יצר רשומה "y" -> 0 רק מעצם האזכור. כדי לבדוק קיום בלי לשנות את ה-map, השתמשו ב-count (מחזיר 0 או 1) או ב-find:

כלל אצבע: השתמשו ב-[] רק כשאתם מתכוונים ליצור או לעדכן. לקריאה בלבד השתמשו ב-count, ב-contains (C++20) או ב-find.

find ו-at: חיפושים בטוחים

find מחזיר iterator לרשומה, או end() אם המפתח לא קיים. הוא אף פעם לא מכניס, והוא מאפשר להגיע גם למפתח וגם לערך דרך it->first ו-it->second בחיפוש אחד:

כשאתם יודעים שהמפתח אמור להיות קיים ורוצים כישלון מובהק אם הוא לא, השתמשו ב-at. בניגוד ל-[], at לא מכניס: הוא זורק std::out_of_range כשהמפתח חסר:

int p = prices.at("pen");   // בסדר
int q = prices.at("hat");   // זורק std::out_of_range: המפתח לא קיים

אז יש לכם שלושה סגנונות חיפוש: [] (מכניס), at (זורק), ו-find/count (מדווחים על קיום בלי לגעת ב-map). בחרו את זה שאופן הכישלון שלו מתאים לכוונה שלכם.

מעבר על איברים ומחיקה

לולאת for מבוססת טווח עם structured bindings היא הדרך הנקייה לעבור על כל רשומה. זכרו שהסדר שרירותי: אף פעם אל תסתמכו עליו:

כדי להסיר רשומה, erase מקבל מפתח ישירות ומחזיר כמה רשומות הוסרו (0 או 1). מלכודת חשובה אחת: מחיקה תוך כדי מעבר מבטלת ב-unordered_map רק את ה-iterator של האיבר שנמחק, אז השתמשו בערך ההחזרה של erase(it) כדי להתקדם בבטחה:

כתיבה בסגנון wins.erase(it++) או ++it אחרי erase(it) רגיל היא מלכודת ה-iterator התלוי הקלאסית: ה-iterator שנמחק מת, אז תמיד קחו את ה-iterator ש-erase מחזיר.

map או unordered_map?

שניהם מאחסנים זוגות מפתח-ערך עם API דומה, אז הבחירה תלויה במה שאתם צריכים:

// std::map            -> מפתחות ממוינים, O(log n), שאילתות טווח (lower_bound)
// std::unordered_map  -> בלי סדר,        O(1) בממוצע, החיפוש הפשוט המהיר ביותר

פנו ל-unordered_map כשצריך רק חיפוש מהיר לפי מפתח והסדר לא רלוונטי (ספירת תדירות מילים, caching, הסרת כפילויות). בחרו ב-map כשצריך מפתחות בסדר ממוין, מעבר לפי הסדר, או שאילתות טווח. שתי הסתייגויות לגבי unordered_map: ה-O(1) שלו הוא ממוצע, ופונקציית hash גרועה יכולה לפגוע בו, וטיפוס מפתח מותאם אישית צריך התמחות של hash או hash functor, בעוד ש-map צריך רק operator<.

הבא בתור: Set

עכשיו ראיתם את שני הסוגים של קונטיינרים של מפתח-ערך. אבל לפעמים לא צריך ערך בכלל: אכפת לכם רק האם משהו קיים, כמו אוסף של תגיות ייחודיות או מזהים שכבר ביקרתם בהם. בהמשך נכיר את std::set (ואת בן הדוד שלו שמבוסס על hash, unordered_set), שמאחסנים רק מפתחות ושומרים אותם ייחודיים אוטומטית.

שאלות נפוצות

מה ההבדל בין map ל-unordered_map ב-C++?

std::map הוא עץ בינארי מאוזן: המפתחות נשמרים ממוינים והפעולות הן O(log n). std::unordered_map הוא טבלת hash: למפתחות אין סדר מסוים, אבל הכנסה וחיפוש הם בממוצע O(1). השתמשו ב-unordered_map כשצריך רק חיפוש מהיר לפי מפתח והסדר לא משנה לכם; השתמשו ב-map כשצריך מעבר ממוין או שאילתות טווח.

האם unordered_map[] מכניס מפתח אם הוא לא קיים?

כן. m[key] יוצר ערך ברירת מחדל ומכניס את המפתח אם הוא חסר, ואז מחזיר הפניה אליו. זה אומר שאפילו if (m[key] == ...) שנראה כמו קריאה בלבד מגדיל את ה-map בשקט. כדי לבדוק אם מפתח קיים בלי להכניס אותו, השתמשו ב-m.count(key) או ב-m.find(key).

האם unordered_map תמיד מהיר יותר מ-map ב-C++?

לא. הוא O(1) בממוצע, אבל ל-hashing יש עלות, ופונקציית hash גרועה (או מפתחות זדוניים) יכולה להאט חיפושים ל-O(n). ב-maps קטנים, הקבועים והתנהגות cache גרועה יותר יכולים להפוך map ממוין למהיר באותה מידה או אפילו יותר. מדדו אם זה חשוב, אבל כברירת מחדל פנו ל-unordered_map כשצריך רק חיפוש לפי מפתח.

איור של שפות התכנות ב-Coddy

ללמוד תכנות עם Coddy

להתחיל