ไม่คาดหมายเวลาทำงานสำหรับรหัส HashSet


28

ตอนแรกฉันมีรหัสนี้:

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));
    }
}

ใช้เวลาประมาณ 4s ในการเปิดใช้งานซ้อนกันสำหรับลูปบนคอมพิวเตอร์ของฉันและฉันไม่เข้าใจว่าทำไมจึงใช้เวลานาน วนรอบด้านนอกรัน 100,000 ครั้งส่วนในของลูปควรรัน 1 ครั้ง (เนื่องจากค่าใด ๆ ของ hashSet จะไม่เป็น -1) และการลบรายการออกจาก HashSet คือ O (1) ดังนั้นควรมีการดำเนินงานประมาณ 200,000 ครั้ง หากโดยทั่วไปมีการดำเนินงาน 100,000,000 ครั้งในหนึ่งวินาทีรหัสของฉันจะใช้เวลา 4s เพื่อให้ทำงานได้อย่างไร

นอกจากนี้หากบรรทัดhashSet.remove(i);ถูกใส่ความคิดเห็นรหัสจะใช้เวลาเพียง 16ms หากความเห็นด้านในสำหรับลูปถูกใส่ความคิดเห็น (แต่ไม่ใช่hashSet.remove(i);) รหัสจะใช้เวลาเพียง 8ms


4
ฉันยืนยันข้อค้นพบของคุณ ฉันสามารถคาดเดาเกี่ยวกับเหตุผลได้ แต่หวังว่าบางคนที่ฉลาดจะโพสต์คำอธิบายที่น่าสนใจ
khelwood

1
ดูเหมือนว่าfor valลูปคือสิ่งที่สละเวลา removeยังคงเป็นไปอย่างรวดเร็วมาก ค่าโสหุ้ยบางอย่างตั้งค่าตัววนซ้ำใหม่หลังจากที่ชุดถูกแก้ไข ... ?
khelwood

@apangin ให้คำอธิบายที่ดีในstackoverflow.com/a/59522575/108326เพราะเหตุใดfor valลูปจึงช้า อย่างไรก็ตามโปรดทราบว่าไม่จำเป็นต้องวนซ้ำเลย หากคุณต้องการที่จะตรวจสอบว่ามีค่าใด ๆ ที่แตกต่างจาก -1 hashSet.size() > 1 || !hashSet.contains(-1)ในชุดก็จะมีประสิทธิภาพมากขึ้นในการตรวจสอบ
markusk

คำตอบ:


32

คุณได้สร้างกรณีการใช้งานเล็กน้อยHashSetซึ่งอัลกอริทึมลดความซับซ้อนลงเป็นสองเท่า

นี่คือลูปแบบง่ายที่ใช้เวลานาน:

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

async-profilerแสดงให้เห็นว่าเกือบทุกครั้งที่ใช้ในการjava.util.HashMap$HashIterator()สร้าง:

    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);
        }
    }

บรรทัดที่ไฮไลต์คือลูปเชิงเส้นที่ค้นหาที่ฝากข้อมูลที่ไม่ว่างเปล่าแรกในตารางแฮช

เนื่องจากIntegerมีเรื่องไม่สำคัญhashCode(เช่น hashCode เท่ากับจำนวนตัวเอง) ปรากฎว่าจำนวนเต็มต่อเนื่องส่วนใหญ่ครอบครองถังที่ต่อเนื่องกันในตารางแฮช: หมายเลข 0 ไปที่ฝากข้อมูลแรกหมายเลข 1 ไปที่ฝากข้อมูลที่สองเป็นต้น

ตอนนี้คุณลบหมายเลขที่ต่อเนื่องกันจาก 0 ถึง 99999 ในกรณีที่ง่ายที่สุด (เมื่อที่ฝากข้อมูลประกอบด้วยคีย์เดียว) การลบคีย์จะถูกนำไปใช้เนื่องจากจะไม่มีผลกับองค์ประกอบที่เกี่ยวข้องในอาร์เรย์ที่ฝากข้อมูล โปรดทราบว่าตารางจะไม่ถูกบีบอัดหรือ rehashed หลังจากการลบ

ดังนั้นยิ่งคุณลบคีย์ออกจากจุดเริ่มต้นของแถวเก็บข้อมูลได้นานเท่าใดHashIteratorความต้องการในการค้นหาที่เก็บข้อมูลที่ไม่ว่างเปล่าชุดแรกจะยิ่งนานขึ้น

ลองเอากุญแจออกจากปลายอีกด้าน:

hashSet.remove(100_000 - i);

อัลกอริทึมจะเร็วขึ้นอย่างมาก!


1
อ่าฉันเจอสิ่งนี้ แต่ไม่สนใจหลังจากผ่านไปสองสามครั้งแรกและคิดว่านี่อาจเป็นการเพิ่มประสิทธิภาพของ JIT และย้ายไปวิเคราะห์ผ่าน JITWatch ควรรัน async-profiler ก่อน ประณาม!
Adwait Kumar

1
ค่อนข้างน่าสนใจ if (i % 800 == 0) { hashSet = new HashSet<>(hashSet); }ถ้าคุณทำสิ่งที่ต้องการต่อไปนี้ในวงที่จะเพิ่มความเร็วขึ้นโดยการลดขนาดของแผนที่ภายใน:
สีเทา - ดังนั้นหยุดชั่วร้าย
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.