0Pricing
Coding Interview Prep · บทเรียน

ภายในฟังก์ชันแฮชและการจัดการการชนกัน

ทำความเข้าใจวิธีที่ Python แฮชออบเจ็กต์ วิธีที่การกำหนดตำแหน่งแบบเปิดและการต่อโซ่แก้การชนกัน และเหตุใด O(1) ในกรณีเฉลี่ยจึงอาจแย่ลงเป็น O(n)

ภายในฟังก์ชันแฮชและการจัดการการชนกัน เป็นบทเรียน Coding Interview Prep ฟรีบน CoddyKit นี่คือบทเรียนที่ 1 จากทั้งหมด 4 บทเรียน คุณสามารถอ่านบทเรียนทั้งหมดด้านล่างฟรี — จากนั้นลองปฏิบัติด้วยตัวคุณเองในเบราว์เซอร์พร้อมตัวแก้ไขโค้ดในตัวและติวเตอร์ AI ตลอด 24/7 บทเรียนนี้เป็นส่วนหนึ่งของเส้นทางการเรียน Coding Interview Prep และความก้าวหน้าของคุณจะซิงค์ข้ามเว็บและแอป CoddyKit คอร์ส Coding Interview Prep มีบทเรียนทั้งหมด 4 บทเรียน

แฮชแมปคืออะไร

แฮชแมป (พจนานุกรมในไพธอน) จับคู่คีย์กับค่าโดยใช้ ฟังก์ชันแฮช ซึ่งแปลงคีย์ใด ๆ เป็นดัชนีจำนวนเต็มในอาร์เรย์พื้นฐาน ฟังก์ชันแฮชในอุดมคติกระจายคีย์ไปทั่วอาร์เรย์อย่างสม่ำเสมอ ทำให้การค้นหา การเพิ่ม และการลบมีความซับซ้อนเฉลี่ยเป็น O(1) อาร์เรย์พื้นฐานนี้เรียกว่า ตารางแฮช หรืออาร์เรย์ช่องจัดเก็บ

ในไพธอน dict เป็นแฮชแมปที่ได้รับการปรับให้เหมาะสมอย่างมาก การเข้าใจโครงสร้างภายในช่วยให้คุณวิเคราะห์พฤติกรรมในกรณีแย่ที่สุดและเลือกคีย์ที่เหมาะสมได้

# Python dict is a hash map
hm = {}
hm['alice'] = 95
hm['bob']   = 87
hm['carol'] = 91

print(hm['alice'])          # O(1) lookup: 95
print('bob' in hm)          # O(1) membership: True
del hm['bob']               # O(1) deletion
print(hm)                   # {'alice': 95, 'carol': 91}

ฟังก์ชันแฮชและเมธอด __hash__

ไพธอนเรียกใช้ __hash__(key) เพื่อคำนวณค่าจำนวนเต็มจากคีย์ จากนั้นนำจำนวนเต็มนั้นมาหารเอาเศษด้วยขนาดตารางเพื่อหาดัชนีช่องจัดเก็บ ชนิดข้อมูลในตัวอย่างเช่น int str และ tuple มีการคำนวณแฮชในตัวที่รวดเร็ว ส่วน list และ dict ไม่สามารถแฮชได้ (เนื่องจากเปลี่ยนแปลงได้ และการเปลี่ยนแปลงจะทำให้ค่าแฮชที่จัดเก็บไว้ใช้ไม่ได้)

ฟังก์ชันแฮชที่ดีควรกระจายคีย์อย่างสม่ำเสมอ ให้ผลลัพธ์แน่นอน และคำนวณได้รวดเร็ว แฮชของสตริงในไพธอนจะสุ่มค่าแตกต่างกันในแต่ละครั้งที่ทำงาน (เป็นคุณลักษณะด้านความปลอดภัย) — ใช้ PYTHONHASHSEED=0 เพื่อปิดการสุ่มและให้ผลทำซ้ำได้ในการทดสอบ

# Built-in hash in Python
print(hash(42))           # integer hashes to itself (CPython)
print(hash('hello'))      # string hash (randomised per run)
print(hash((1, 2, 3)))    # tuple hash: depends on contents

# Unhashable types
try:
    hash([1, 2, 3])       # lists are mutable -> not hashable
except TypeError as e:
    print('Error:', e)

# Custom class: define __hash__ and __eq__
class Point:
    def __init__(self, x, y): self.x = x; self.y = y
    def __hash__(self): return hash((self.x, self.y))
    def __eq__(self, other): return self.x == other.x and self.y == other.y

points = {Point(1, 2): 'A', Point(3, 4): 'B'}
print(points[Point(1, 2)])  # 'A'

การชนกัน: เมื่อคีย์สองรายการแฮชไปยังช่องเดียวกัน

การชนกัน เกิดขึ้นเมื่อคีย์ที่แตกต่างกันสองคีย์ให้ดัชนีช่องจัดเก็บเดียวกัน การชนกันเป็นสิ่งที่หลีกเลี่ยงไม่ได้ (ตามหลักรังนกพิราบ: มีคีย์จำนวนไม่สิ้นสุด แต่มีช่องจัดเก็บจำนวนจำกัด) กลยุทธ์มาตรฐานในการแก้ปัญหามีสองแบบคือ การเชื่อมโยง และ การระบุตำแหน่งแบบเปิด ไพธอนใช้รูปแบบหนึ่งของการระบุตำแหน่งแบบเปิดร่วมกับการไล่ตรวจแบบสุ่มเทียม

การเชื่อมโยงจะจัดเก็บรายการเชื่อมโยง (หรืออาร์เรย์แบบไดนามิก) ไว้ในแต่ละช่องจัดเก็บ คีย์ทั้งหมดที่ชนกันในช่องนั้นจะรวมกันเป็นสายโซ่ ส่วนการระบุตำแหน่งแบบเปิดจะค้นหาช่องจัดเก็บถัดไปที่ว่างตามลำดับการไล่ตรวจ

# Simplified chaining hash map
class ChainingHashMap:
    def __init__(self, capacity=8):
        self.capacity = capacity
        self.buckets  = [[] for _ in range(capacity)]

    def _idx(self, key):
        return hash(key) % self.capacity

    def put(self, key, val):
        bucket = self.buckets[self._idx(key)]
        for i, (k, v) in enumerate(bucket):
            if k == key:
                bucket[i] = (key, val)
                return
        bucket.append((key, val))

    def get(self, key):
        for k, v in self.buckets[self._idx(key)]:
            if k == key:
                return v
        return None

hm = ChainingHashMap()
hm.put('a', 1); hm.put('b', 2)
print(hm.get('a'))  # 1
print(hm.get('c'))  # None

การระบุตำแหน่งแบบเปิด: การไล่ตรวจเชิงเส้น

ในการ ไล่ตรวจเชิงเส้น เมื่อเกิดการชนกันที่ดัชนี i แฮชแมปจะตรวจสอบ i+1, i+2, ... (วนกลับไปต้นอาร์เรย์เมื่อถึงปลาย) จนกว่าจะพบช่องว่าง การค้นหาต้องไล่ตรวจตามลำดับเดียวกันเพื่อค้นหาคีย์ การลบต้องใช้เครื่องหมายแทนที่การลบแทนการล้างช่อง เพื่อไม่ให้สายโซ่การไล่ตรวจขาดตอน

การกระจุกตัวเป็นข้อเสียหลัก เมื่อเกิดกลุ่มช่องที่มีข้อมูลแล้ว การเพิ่มข้อมูลในบริเวณนั้นในอนาคตจะทำให้กลุ่มขยายใหญ่ขึ้น ส่งผลให้ประสิทธิภาพลดลงจนเข้าใกล้ O(n)

class LinearProbingHashMap:
    DELETED = object()  # tombstone sentinel

    def __init__(self, capacity=8):
        self.capacity = capacity
        self.keys  = [None] * capacity
        self.vals  = [None] * capacity
        self.size  = 0

    def _probe(self, key):
        idx = hash(key) % self.capacity
        while self.keys[idx] is not None and self.keys[idx] != key:
            idx = (idx + 1) % self.capacity
        return idx

    def put(self, key, val):
        idx = self._probe(key)
        if self.keys[idx] is None:
            self.size += 1
        self.keys[idx] = key
        self.vals[idx] = val

    def get(self, key):
        idx = self._probe(key)
        if self.keys[idx] == key:
            return self.vals[idx]
        return None

hm = LinearProbingHashMap()
hm.put('x', 10); hm.put('y', 20)
print(hm.get('x'))  # 10

อัตราการโหลดและการปรับขนาด

อัตราการโหลดคืออัตราส่วนระหว่างรายการที่จัดเก็บกับความจุทั้งหมด: α = n/m เมื่อ α เพิ่มขึ้น ความน่าจะเป็นที่จะเกิดการชนกันจะสูงขึ้นและประสิทธิภาพจะลดลง พจนานุกรมของไพธอนจะปรับขนาด (เพิ่มความจุเป็นสองเท่า) เมื่ออัตราการโหลดสูงเกินประมาณ 2/3 การปรับขนาดจะคำนวณแฮชของรายการเดิมทั้งหมดใหม่เพื่อจัดเก็บลงในตารางที่ใหญ่ขึ้น — เป็นการดำเนินการ O(n) ที่เกิดขึ้นไม่บ่อย จึงทำให้ต้นทุนการเพิ่มข้อมูลเฉลี่ยตลอดการใช้งานเป็น O(1)

import sys

d = {}
prev_size = sys.getsizeof(d)
for i in range(30):
    d[i] = i
    new_size = sys.getsizeof(d)
    if new_size != prev_size:
        print(f'Resized at n={i+1}: {prev_size} -> {new_size} bytes')
        prev_size = new_size

ค่าเฉลี่ย O(1) เทียบกับกรณีแย่ที่สุด O(n)

ภายใต้ฟังก์ชันแฮชที่ดี การชนกันจะเกิดขึ้นไม่บ่อย และความยาวสายโซ่ที่คาดหมายจะคงที่โดยไม่ขึ้นกับ n ดังนั้นการค้นหา การเพิ่ม และการลบในกรณีเฉลี่ยจึงมีความซับซ้อนเป็น O(1) อย่างไรก็ตาม กรณีแย่ที่สุด — เช่น ข้อมูลนำเข้าที่จงใจสร้างขึ้นเพื่อโจมตีและทำให้คีย์ทั้งหมดไปอยู่ในช่องเดียวกัน — จะทำให้การดำเนินการทั้งหมดมีความซับซ้อนเป็น O(n) เมล็ดแฮชแบบสุ่มของไพธอนช่วยลดผลกระทบจากการโจมตีนี้ แต่ในทางทฤษฎียังไม่สามารถกำจัดกรณีแย่ที่สุดได้

ในการวิเคราะห์สำหรับการสัมภาษณ์ ให้กล่าวว่า “O(1) โดยเฉลี่ย และ O(n) ในกรณีแย่ที่สุดเนื่องจากการชนกัน”

# Python randomised hash seed prevents worst-case hash-flooding
import os
print('PYTHONHASHSEED:', os.environ.get('PYTHONHASHSEED', 'random'))
# By default Python randomises the hash of strings each run
# This prevents an attacker from crafting keys that all collide
# To reproduce results in testing: PYTHONHASHSEED=0 python script.py

พจนานุกรมไพธอนเทียบกับพจนานุกรมค่าเริ่มต้นและตัวนับ

ไพธอนมีแฮชแมปสามรูปแบบที่ควรรู้จัก dict คือแฮชแมปสำหรับใช้งานทั่วไป การเข้าถึงคีย์ที่ไม่มีอยู่จะทำให้เกิด KeyError ส่วน defaultdict(factory) จะคืนค่าเริ่มต้นเมื่อเข้าถึงคีย์ที่ไม่มีอยู่ (มีประโยชน์สำหรับการรวบรวมรายการหรือการนับ) และ Counter เป็นคลาสย่อยเฉพาะทางสำหรับนับวัตถุที่แฮชได้ ทั้งยังรองรับการดำเนินการทางคณิตศาสตร์ระหว่างตัวนับด้วย

from collections import defaultdict, Counter

# defaultdict for grouping
groups = defaultdict(list)
for word in ['apple', 'ant', 'banana', 'bee', 'avocado']:
    groups[word[0]].append(word)
print(dict(groups))
# {'a': ['apple','ant','avocado'], 'b': ['banana','bee']}

# Counter for frequency
c = Counter('abracadabra')
print(c.most_common(3))  # [('a',5),('b',2),('r',2)]
print(c['a'] - Counter('aa')['a'])  # counter subtraction

แฮชแมปเทียบกับเซตแฮช

เซตแฮชจัดเก็บเฉพาะคีย์ (ไม่มีค่าที่เชื่อมโยง) และรองรับการตรวจสอบสมาชิก การเพิ่ม และการลบด้วยความซับซ้อน O(1) set ของไพธอนคือเซตแฮช ควรใช้เซตเมื่อคุณต้องการเพียงตอบว่า “องค์ประกอบนี้มีอยู่หรือไม่” โดยไม่ต้องจัดเก็บข้อมูลที่เกี่ยวข้อง และควรใช้พจนานุกรมเมื่อจำเป็นต้องเชื่อมโยงค่าต่าง ๆ (จำนวน ผลลัพธ์ และอื่น ๆ) เข้ากับคีย์

# set for membership testing
visited = set()
for node in [1, 3, 5, 3, 7, 1]:
    if node not in visited:
        print('New node:', node)
        visited.add(node)

# Set operations: union, intersection, difference
A = {1, 2, 3, 4}
B = {3, 4, 5, 6}
print('Union:', A | B)         # {1,2,3,4,5,6}
print('Intersection:', A & B)  # {3,4}
print('Difference:', A - B)    # {1,2}

การสร้างแฮชแมปตั้งแต่ต้น (ฉบับสัมภาษณ์)

ผู้สัมภาษณ์อาจขอให้คุณสร้างแฮชแมปพื้นฐาน องค์ประกอบสำคัญมีดังนี้: อาร์เรย์ช่องจัดเก็บขนาดคงที่ (ใช้ 16 หรือ 1024) แต่ละช่องจัดเก็บเป็นรายการคู่ (คีย์, ค่า) สำหรับการเชื่อมโยง ฟังก์ชันแฮช (ใช้แฮชในตัวของไพธอน % ความจุ) และการปรับขนาดเมื่ออัตราการโหลดสูงเกิน 0.7 การกล่าวถึงการปรับขนาดและอัตราการโหลดไว้ก่อนจะแสดงให้เห็นถึงความรู้ความเข้าใจในระดับลึก

class HashMap:
    def __init__(self, capacity=16):
        self.capacity = capacity
        self.size     = 0
        self.buckets  = [[] for _ in range(capacity)]

    def _hash(self, key):
        return hash(key) % self.capacity

    def put(self, key, val):
        b = self.buckets[self._hash(key)]
        for i, (k, v) in enumerate(b):
            if k == key:
                b[i] = (key, val)
                return
        b.append((key, val))
        self.size += 1
        if self.size / self.capacity > 0.7:
            self._resize()

    def get(self, key, default=None):
        for k, v in self.buckets[self._hash(key)]:
            if k == key:
                return v
        return default

    def _resize(self):
        old = self.buckets
        self.capacity *= 2
        self.buckets = [[] for _ in range(self.capacity)]
        self.size = 0
        for bucket in old:
            for k, v in bucket:
                self.put(k, v)

hm = HashMap()
for i in range(20):
    hm.put(i, i * 2)
print(hm.get(10))   # 20
print(hm.capacity)  # should have resized

เมื่อแฮชแมปใช้ไม่ได้: คีย์ที่แฮชไม่ได้

เฉพาะวัตถุที่ แฮชได้เท่านั้นที่ใช้เป็นคีย์ของพจนานุกรมได้ ในไพธอน วัตถุจะแฮชได้หากมีเมธอด __hash__ และเมธอด __eq__ และค่าแฮชของวัตถุนั้นไม่เปลี่ยนแปลงตลอดช่วงอายุการใช้งาน ลิสต์ เซต และพจนานุกรมเปลี่ยนแปลงได้ จึงไม่สามารถแฮชได้ ทูเพิลและฟรอเซนเซตเป็นทางเลือกที่แฮชได้แทนลิสต์และเซตเมื่อต้องใช้เป็นคีย์

กับดักในการสัมภาษณ์ที่พบบ่อยคือ การจัดกลุ่มแอนนาแกรมต้องใช้ทูเพิลที่เรียงลำดับ (ไม่ใช่ลิสต์ที่เรียงลำดับ) เป็นคีย์ของพจนานุกรม

from collections import defaultdict

def groupAnagrams(strs):
    groups = defaultdict(list)
    for s in strs:
        key = tuple(sorted(s))  # tuple is hashable; list is not
        groups[key].append(s)
    return list(groups.values())

print(groupAnagrams(['eat','tea','tan','ate','nat','bat']))
# [['eat','tea','ate'], ['tan','nat'], ['bat']]

สรุป: ความซับซ้อนของแฮชแมป

แฮชแมปให้ความซับซ้อนเฉลี่ยเป็น O(1) สำหรับการเพิ่ม การลบ และการค้นหา ซึ่งเป็นพื้นฐานของวิธีแก้โจทย์สัมภาษณ์ที่มีประสิทธิภาพสูงหลายรูปแบบ สมมติฐานสำคัญคือ ฟังก์ชันแฮชกระจายคีย์อย่างสม่ำเสมอ อัตราการโหลดมีขอบเขตที่เหมาะสม (การปรับขนาดช่วยรักษาเงื่อนไขนี้) และวัตถุที่ใช้เป็นคีย์ไม่เปลี่ยนแปลงและแฮชได้ เมื่อสมมติฐานเหล่านี้เป็นจริง แฮชแมปจะเปลี่ยนการสแกนเชิงเส้น O(n) ให้เป็นการค้นหา O(1) ทำให้สามารถแก้โจทย์อย่างผลรวมสองค่าได้ใน O(n) แทน O(n²)

ตรวจสอบความเข้าใจ

ทดสอบความเข้าใจแนวคิดโครงสร้างข้อมูลและอัลกอริทึม — การเตรียมตัวสัมภาษณ์การเขียนโปรแกรมจากบทเรียนนี้

สรุปบทเรียน

ในบทเรียนนี้ คุณได้เรียนรู้ว่า แฮชแมปจับคู่คีย์กับดัชนีช่องจัดเก็บด้วยฟังก์ชันแฮช และทำให้การดำเนินการมีความซับซ้อนเฉลี่ยเป็น O(1) การชนกันแก้ไขได้ด้วยการเชื่อมโยง (รายการเชื่อมโยงหนึ่งรายการต่อช่องจัดเก็บ) หรือการระบุตำแหน่งแบบเปิด (การไล่ตรวจหาช่องว่างถัดไป) และ เฉพาะวัตถุที่ไม่เปลี่ยนแปลงและแฮชได้เท่านั้นที่ใช้เป็นคีย์ของพจนานุกรมได้ — ใช้ทูเพิลแทนลิสต์เมื่อต้องการคีย์ที่เป็นลำดับ บทถัดไปเราจะฝึกแก้โจทย์ผลรวมสองค่าและรูปแบบต่าง ๆ ที่พบบ่อยในการสัมภาษณ์

คำถามที่พบบ่อย

บทเรียน “ภายในฟังก์ชันแฮชและการจัดการการชนกัน” ฟรีหรือไม่

ใช่ — ข้อความเต็มของ “ภายในฟังก์ชันแฮชและการจัดการการชนกัน” ฟรีให้อ่านที่นี่บนเว็บ เพื่อปฏิบัติแบบโต้ตอบ (ตัวแก้ไขโค้ดในตัวและติวเตอร์ AI ตลอด 24/7) และปลดล็อคส่วนที่เหลือของคอร์ส Coding Interview Prep ให้อัปเกรดเป็น CoddyKit PRO คอร์ส Coding Interview Prep มีบทเรียนทั้งหมด 4 บทเรียน

คุณจะเรียนรู้อะไรในบทเรียน “ภายในฟังก์ชันแฮชและการจัดการการชนกัน”

ทำความเข้าใจวิธีที่ Python แฮชออบเจ็กต์ วิธีที่การกำหนดตำแหน่งแบบเปิดและการต่อโซ่แก้การชนกัน และเหตุใด O(1) ในกรณีเฉลี่ยจึงอาจแย่ลงเป็น O(n) คุณปฏิบัติ Coding Interview Prep ด้วยโค้ดที่ใช้งานได้จริงที่คุณเรียกใช้โดยตรงในเบราว์เซอร์ และติวเตอร์ AI ตลอด 24/7 ตอบคำถามของคุณขณะที่คุณไปผ่านบทเรียน

คุณต้องมีประสบการณ์ก่อนที่จะเริ่มเรียน Coding Interview Prep หรือไม่

ไม่จำเป็นต้องมีประสบการณ์มาก่อน Coding Interview Prep บน CoddyKit ออกแบบมาสำหรับผู้เริ่มต้นไปจนถึงผู้เรียนขั้นสูง คุณสามารถเริ่มต้นที่นี่หรือเริ่มจากตัวแรกและเรียนด้วยความเร็วของคุณเอง นี่คือบทเรียนที่ 1 จากทั้งหมด 4 บทเรียน

บทเรียน “ภายในฟังก์ชันแฮชและการจัดการการชนกัน” ใช้เวลานานแค่ไหน

บทเรียน CoddyKit ส่วนใหญ่ใช้เวลาประมาณ 5–10 นาที แต่ละบทเรียนจึงสั้นและเป็นแบบโต้ตอบ คุณสามารถก้าวหน้าอย่างต่อเนื่องและกลับมาเรียนต่อจากตรงที่เพิ่งหยุดบนเว็บและแอปได้เลย

ฉันเขียนและรันโค้ดในบทเรียน Coding Interview Prep นี้ได้ไหม

ได้ บทเรียน Coding Interview Prep ทุกบทมีตัวแก้ไขโค้ดในตัว คุณจึงเขียนและรันโค้ดจริงได้เลยในเบราว์เซอร์ และได้รับข้อเสนอแนะจาก AI ในทันที — ไม่ต้องติดตั้งในเครื่องของคุณ

บทเรียนทั้งหมดในหลักสูตรนี้

  1. ภายในฟังก์ชันแฮชและการจัดการการชนกัน
  2. ผลรวมสองค่าและรูปแบบหลากหลาย
  3. การนับความถี่และการจัดกลุ่ม
  4. ลำดับต่อเนื่องที่ยาวที่สุดและแคช LRU
← กลับไปที่ Coding Interview Prep