Bảng băm (Hash Table)

Bảng băm (Hash Table) là một CTDL cho phép ta lưu trữ và truy cập thông tin một cách nhanh chóng, dẫu cho dữ liệu có lớn cỡ nào đi nữa.

Ý tưởng

Giả sử ta muốn lưu các xâu kí tự trên một mảng giá trị. Ta có thể gán các xâu vào các phần tử bất kì. Tuy nhiên việc tìm lại vị trí mà ta đã lưu xâu ấy sẽ rất khó khăn vì ta không có thông tin gì về nó.

Ý tưởng chính của bảng băm chính là hô biến xâu thành chỉ số bằng một hàm băm (hash function), và sử dụng chỉ số ấy để lưu giá trị mà ta cần lưu trên phần tử có chỉ số ấy. Khi này, việc tìm lại giá trị về xâu cũng sẽ nhanh chóng hơn khi ta chỉ cần sử dụng hàm băm tìm chỉ số từ xâu và lấy thông tin từ phần tử ở chỉ số đó.

Bảng băm

Bảng băm là một CTDL lưu các cặp dữ liệu \(key, value\). Các \(key\) sẽ là thứ quyết định các \(value\) được lưu ở đâu trong bảng băm. Ở ví dụ dưới đây, ta sẽ cho \(k = value\) để dễ minh họa giải thích.


Ta có mảng h gồm \(8\) có thể lưu xâu kí tự (zero-indexed), và ta muốn lưu \(8\) xâu sau vào mảng: Anh, Bao, Hien, Hoang, Huy, Khang, Hao, Phuc (các tên không dấu).

Ta có hàm băm getHash băm các xâu để tạo chỉ số để lưu xâu trên mảng h. getHash sẽ tạo chỉ số bằng cách tính tổng ASCII của các kí tự trong xâu, modulo cho kích thước mảng.

int getHash(string &s){
	int sum = 0;
	for(char c : s){
		sum += int(c);
	}
	return sum % 8;
}

Như xâu Anh khi đưa vào hàm getHash sẽ trả về chỉ số \((65 + 110 + 104) \mod{8} = 7\). Vì vậy, xâu Anh sẽ được lưu tại chỉ số \(7\) của mảng h.

Lưu 'Anh' vào mảng h

Tiếp theo với xâu Bao, getHash trả về chỉ số \((66 + 97 + 111) \mod{8} = 2\), ta lưu Bao tại phần tử có chỉ số \(2\).

Lưu 'Bao' vào mảng h

Và tiếp tục với Hien, Hoang, Huy, Khang, Hao.

Lưu 'Hien', 'Hoang', 'Huy', 'Khang', 'Hao* vào mảng h

Đến xâu cuối cùng Phuc, getHash sẽ băm xâu Phuc ra và cho ta chí số \(0\), nhưng tại chỉ số \(0\) đã có Hao rồi. Xử lí sao bây giờ?

Hash collision

Va chạm băm (Hash collision) xảy ra khi hai giá trị có cùng giá trị băm, giống như trường hợp đã xảy ra giữa PhucHao. Để khắc phục vẫn đề này khi lưu trên bảng băm, ta có hai phương pháp chính: phương pháp tạo dây chuyền (seperate chaining) và phương pháp địa chỉ mở (open addressing).

Phương pháp chuỗi (separate chaining)

Phương pháp chuỗi (separate chaining) thay đổi các phần tử trong bảng băm - thay vì chỉ lưu một giá trị, các phần tử có thể lưu một chuỗi các giá trị khác nhau.

Lưu 'Phuc' vào mảng h

Phương pháp địa chỉ mở (open addressing)

Phương pháp địa chỉ mở (open addressing) xử lí va chạm băm bằng cách dịch giá trị băm đến vị trí bên phải gần nhất sao cho phần tử ở vị trí ấy chưa lưu giá trị nào.

Ở ví dụ này, vì \(3\) là chỉ số gần chỉ số \(0\) về phía bên phải mà phần tử chưa lưu giá trị nào nên ta lưu Phuc tại vị trí đó.

Lưu 'Phuc' vào mảng h

Hệ số tải

Hệ số tải (load factor) \(\alpha\) cho biết độ hiệu quả của hàm băm khi lưu các giá trị, được tính bằng công thức:

\[\alpha = \frac{n}{m}\]

Trong đó, \(n\) là số giá trị được lưu trong bảng băm, còn \(m\) là số lượng phần tử mà bảng băm có.

Tìm kiếm

Để tìm kiếm các giá trị mà ta đã lưu trong bảng băm, ta sẽ lấy giá trị của hàm băm và kiểm tra nếu vị trí đó có lưu giá trị cần tìm hay không. Nếu bảng băm áp dụng phương pháp chuỗi thì kiểm tra các giá trị trong chuỗi, nếu là phương pháp địa chỉ mở thì kiểm tra các giá trị ở bên phái.

Cài đặt

Dưới đây là cách cài đặt hàm băm để lưu các xâu kí tự, sử dụng phương pháp địa chỉ mở nếu có va chạm băm.

struct HashTable{
	vector<vector<string>> h; // các chuỗi
	int m;
	HashTable(int _m){ // m
		m = _m;
		h.resize(_m);
	}
	// băm xâu
	int getHash(string &s){
		int sum = 0;
		for(char c : s){
			sum += int(c);
		}
		return sum % m;
	}
	// thêm xâu vào bảng băm
	void insert(string &s){
		h[getHash(s)].emplace_back(s);
	}
	// tìm xâu trong bảng băm
	bool find(string &s){
		int idx = getHash(s);
		for(auto &str : p[idx]){
			if(str == s) return 1;
		}
		return 0;
	}
};

Bảng băm trong C++

Trong C++, unordered_mapunordered_set là hai container trong thư viện STL của C++ được cài đặt dựa trên ý tưởng của bảng băm - trong khi unordered_map lưu cả \(key\) và \(value\) thì unordered_set chỉ lưu các \(key\).

Ví dụ với unordered_map:

unordered_map<string, int> ump;
ump["a"] = 1;
ump["b"] = 2;
ump["c"] = 3;
cout << (ump.find("a") != ump.end()) << '\n'; // nếu "a" có trong ump -> 1
ump.erase("a"); // xóa "a"
cout << ump["b"] + ump["c"] << '\n'; // 5

Ngoài ra còn có unordered_multimap với unordered_multiset cho phép ta có nhiều \(key\) thay vì chỉ \(1\) như cai cái trên.