Thời gian chạy bất ngờ cho mã Hashset


28

Vì vậy, ban đầu, tôi đã có mã này:

import java.util.*;

public class sandbox {
    public static void main(String[] args) {
        HashSet<Integer> hashSet = new HashSet<>();
        for (int i = 0; i < 100_000; i++) {
            hashSet.add(i);
        }

        long start = System.currentTimeMillis();

        for (int i = 0; i < 100_000; i++) {
            for (Integer val : hashSet) {
                if (val != -1) break;
            }

            hashSet.remove(i);
        }

        System.out.println("time: " + (System.currentTimeMillis() - start));
    }
}

Phải mất khoảng 4 giây để chạy các vòng lặp lồng nhau trên máy tính của tôi và tôi không hiểu tại sao phải mất nhiều thời gian như vậy. Vòng lặp bên ngoài chạy 100.000 lần, vòng lặp for bên trong sẽ chạy 1 lần (vì bất kỳ giá trị nào của hàm băm sẽ không bao giờ là -1) và việc xóa một mục khỏi Hashset là O (1), do đó cần có khoảng 200.000 thao tác. Nếu thường có 100.000.000 hoạt động trong một giây, tại sao mã của tôi mất 4 giây để chạy?

Ngoài ra, nếu dòng hashSet.remove(i);được nhận xét, mã chỉ mất 16ms. Nếu vòng lặp for bên trong được nhận xét (nhưng không hashSet.remove(i);), mã chỉ mất 8ms.


4
Tôi xác nhận phát hiện của bạn. Tôi có thể suy đoán về lý do, nhưng hy vọng ai đó thông minh sẽ đăng một lời giải thích hấp dẫn.
khelwood

1
Có vẻ như for valvòng lặp là thứ chiếm thời gian. Các removevẫn là rất nhanh. Một số loại chi phí thiết lập trình lặp mới sau khi bộ đã được sửa đổi ...?
khelwood

@apangin cung cấp một lời giải thích tốt trong stackoverflow.com/a/59522575/108326 về lý do tại sao for valvòng lặp chậm. Tuy nhiên, lưu ý rằng vòng lặp không cần thiết chút nào. Nếu bạn muốn kiểm tra xem có bất kỳ giá trị nào khác với -1 trong tập hợp hay không, việc kiểm tra sẽ hiệu quả hơn nhiều hashSet.size() > 1 || !hashSet.contains(-1).
markusk

Câu trả lời:


32

Bạn đã tạo ra một trường hợp sử dụng cận biên của HashSet, trong đó thuật toán giảm xuống mức độ phức tạp bậc hai.

Đây là vòng lặp đơn giản hóa mất nhiều thời gian:

for (int i = 0; i < 100_000; i++) {
    hashSet.iterator().next();
    hashSet.remove(i);
}

async-profiler cho thấy rằng hầu hết thời gian được dành cho bên trong hàm java.util.HashMap$HashIterator()tạo:

    HashIterator() {
        expectedModCount = modCount;
        Node<K,V>[] t = table;
        current = next = null;
        index = 0;
        if (t != null && size > 0) { // advance to first entry
--->        do {} while (index < t.length && (next = t[index++]) == null);
        }
    }

Dòng được tô sáng là một vòng lặp tuyến tính tìm kiếm nhóm không trống đầu tiên trong bảng băm.

Integercó giá trị tầm thường hashCode(tức là hashCode bằng chính số đó), hóa ra các số nguyên liên tiếp chủ yếu chiếm các nhóm liên tiếp trong bảng băm: số 0 đi vào nhóm thứ nhất, số 1 chuyển sang nhóm thứ hai, v.v.

Bây giờ bạn xóa các số liên tiếp từ 0 đến 99999. Trong trường hợp đơn giản nhất (khi nhóm chứa một khóa duy nhất), việc loại bỏ một khóa được thực hiện như loại bỏ phần tử tương ứng trong mảng xô. Lưu ý rằng bảng không được nén hoặc làm lại sau khi loại bỏ.

Vì vậy, bạn càng loại bỏ nhiều khóa từ đầu mảng xô, thì càng HashIteratorcần tìm thùng không trống đầu tiên.

Cố gắng loại bỏ các phím từ đầu kia:

hashSet.remove(100_000 - i);

Thuật toán sẽ trở nên nhanh hơn đáng kể!


1
Ahh, tôi đã xem qua nó nhưng đã loại bỏ nó sau vài lần chạy đầu tiên và nghĩ rằng đây có thể là một số tối ưu hóa JIT và chuyển sang phân tích thông qua JITWatch. Nên chạy async-profiler trước. Chỉ trích!
Chờ đợi Kumar

1
Khá thú vị. Nếu bạn làm một cái gì đó như sau trong vòng lặp, nó sẽ tăng tốc độ bằng cách giảm kích thước của bản đồ bên trong : if (i % 800 == 0) { hashSet = new HashSet<>(hashSet); }.
Xám - SO ngừng ác
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.