【问题标题】:What is the complexity of HashMap#replace?HashMap#replace 的复杂度是多少?
【发布时间】:2021-10-25 10:30:26
【问题描述】:

我想知道 replace(Key , Value) 对于 HashMap 的复杂性是什么。

我最初的想法是O(1),因为获取值是O(1),我可以简单地替换分配给键的值。

我不确定我是否应该考虑在用java.util 用 java 实现的大型哈希映射中可能存在的冲突。

【问题讨论】:

  • 它是O(1) 摊销,就像containsKeyputremove。没有摊销,它可能是O(n),因为任何更改都可能触发重新散列,可能会触及所有条目。但是谁在乎非摊销分析。
  • 我想扩展“但谁在乎非摊销分析。”:非摊销分析只在实时用例中很重要,其中每个项目的最长时间必须严格执行。几乎所有用例都更关心吞吐量和平均运行时间,然后非摊销用例就变得无关紧要了。
  • 以防万一,如果您想知道what is amortized time?
  • 其实replace只是改变了,它不受散列的影响。所以它实际上运行的复杂度与getcontains 相同。
  • @Zabuzard 这正是我的想法,但你第一条评论的投票数让我觉得我可能错了:P

标签: java algorithm hashmap time-complexity


【解决方案1】:

tl:dr

HashMap#replaceO(1) amortized 中运行;

并且在地图适当平衡的前提下,Java 在您的 putremove 调用期间负责,也是非摊销

非摊销

它是否也适用于非摊销分析取决于关于实施的自平衡机制的问题。

基本上,由于replace只是改变value,不影响散列和HashMap的一般结构,替换一个值不会触发任何重新散列或重组内部结构。

因此我们只支付定位key 的费用,这取决于存储桶大小

如果地图是适当的自平衡,桶大小可以被认为是一个常数。导致O(1)replace 也未摊销。

但是,该实施仅基于启发式因素触发自平衡和重新散列。对此的深入分析有点复杂。

因此,由于启发式方法,现实可能介于两者之间。


实施

当然,让我们看看current implementation(Java 16):

@Override
public V replace(K key, V value) {
    Node<K,V> e;
    if ((e = getNode(key)) != null) {
        V oldValue = e.value;
        e.value = value;
        afterNodeAccess(e);
        return oldValue;
    }
    return null;
}

方法afterNodeAccess 是子类的虚拟对象,在HashMap 中为空。 除了getNode 之外的所有内容都在O(1) 中运行。

getNode

getNode 是在HashMap 中定位条目的规范实现,我们知道它在O(1) 中运行以获得适当的自平衡映射,如Java 实现。一起来看看code

/**
 * Implements Map.get and related methods.
 *
 * @param key the key
 * @return the node, or null if none
 */
final Node<K,V> getNode(Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n, hash; K k;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & (hash = hash(key))]) != null) {
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

这个方法基本上是计算哈希hash = hash(key),然后在tablefirst = tab[(n - 1) &amp; (hash = hash(key))]中查找哈希并开始遍历存储在bucket中的数据结构。

关于存储桶的数据结构,我们在if (first instanceof TreeNode) 进行了一些分支。

桶要么是简单的隐式链表,要么是红黑树。

链表

对于链表,我们有一个简单的迭代

do {
     if (e.hash == hash &&
        ((k = e.key) == key || (key != null && key.equals(k))))
        return e;
} while ((e = e.next) != null);

显然在O(m) 中运行,m 是链表的大小。

红黑树

对于red-black-tree,我们有

return ((TreeNode<K,V>)first).getTreeNode(hash, key);

在红黑树中的查找是O(log m)m 是树的大小。

桶大小

Java 实现确保在检测到它失控时通过重新散列重新平衡存储桶(您需要为每个修改方法(如 putremove)付费)。

因此,在这两种情况下,我们都可以将桶的大小视为常数,或者由于涉及自平衡的启发式方法,接近常数。

结论

有效地使存储桶大小不变,使getNodeO(1) 中运行,从而导致replace 也在O(1) 中运行。

如果没有任何自平衡机制,在最坏的情况下,如果使用链表,它将降级为O(n),而对于红黑树,它将降级为O(log n)(对于所有键都会产生哈希冲突的情况)。

您可以随意深入挖掘代码,但那里会变得更复杂。

【讨论】:

  • 不,它 不是 O(1),它是 O(1) 摊销的。有大量冲突的表是 log(m),其中 m 是目标桶中的条目数。
  • @YannTM 您在putremove 期间支付碰撞费用,而不是在定位containsKeygetreplace 期间。请参阅最后几段了解为什么tldr:由于重新散列和自平衡,桶大小可以被认为是恒定
  • 这太荒谬了,在哈希表中查找根本不是 O(1)。只有在没有任何冲突的情况下才会出现这种情况。表中有一点冲突是正常的,put/remove 不会保证没有冲突。
  • @YannTM 你的结论是不正确的,假设每次单个存储桶超过100 的大小时,你都会增加并重新散列整个表。有了这个,你可以认为桶大小是一个常数,已经独立于n,尽管有冲突。自平衡处理这些事情。也就是说,当前的实现使用启发式方法来确定自平衡因素,因此介于两者之间。
  • 我更喜欢新的措辞,感谢您的编辑。
【解决方案2】:

你是对的,主要成本是查找,摊销 O(1)。

一旦我们找到正确的位置,用新的值替换相关值是 O(1)。但查找只是摊销 O(1)。

如 Zabuzard 的 incorrect 答案所附的代码所示,Java HashMap 使用经典方法,如果幸运的话(您要查找的条目是存储桶中的第一个),您会得到O(1)。

如果你不太幸运或者你的哈希函数质量很差(假设最坏的情况,所有元素都映射到同一个哈希键),以避免遇到在桶,Java 的实现使用 TreeMap 来提供 O(log n) 复杂度。

因此,如果使用正确,Java 的 hashmap 应该基本上会产生 O(1) 替换,如果使用不正确,则会优雅地降低到 O(log n) 复杂度。阈值在 TREEIFY 中(例如,现代实现中的值为 8)。

请查看源代码中的这些实施说明: https://github.com/AdoptOpenJDK/openjdk-jdk11/blob/master/src/java.base/share/classes/java/util/HashMap.java#L143-L231

【讨论】:

    【解决方案3】:

    基础知识:

    • java.util.HashMap 将调整自身大小以匹配给定数量的元素
    • 所以碰撞非常罕见(与 n 相比)
    • (对于冲突,)现代 HashMap 实现在桶内使用树(NodeTreeNode

    在一次替换/包含/放置/获取操作中,桶冲突

    • 如果你有 k 个桶碰撞,那是 k,
    • 随着树搜索减少到 O(log2(k))
    • 在 O 表示法中,k 是一个小数,相当于 O(1)。

    此外,最坏的情况,哈希冲突

    • 如果你有一个真正的哈希生成器,它总是给出相同的结果
    • 所以我们得到哈希冲突
    • 对于哈希冲突,Node 实现功能类似于 LinkedList
    • 您将拥有(使用类似 LinkedList 的搜索)O(n/2) = O(n) 复杂度。
    • 但这必须是故意的,因为
    • 主因子分布和主数模得到非常好的分布,只要你没有太多相同的hashCode()s
    • 大多数 IDE 或简单的 ID 排序(如数据库中的主键)将提供近乎完美的分布
      • 使用 id-sequenced 哈希函数,您不会有任何(哈希或桶)冲突,因此您实际上可以只使用数组索引而不是哈希函数和冲突处理

    另外,请自行查看 cmets 和代码:https://hg.openjdk.java.net/jdk8/jdk8/jdk/file/687fd7c7986d/src/share/classes/java/util/HashMap.java

    • tableSizeFor(int cap)
    • getNode()

    具体来说:

    • 设置bucket数组的表大小已经接近使用素数了,基本上就是2^n - 1
    • 获取桶是 first = tab[(n - 1) &amp; hash]) 'first' 是桶
      • 正如我所说,这不是模运算,而只是按位与,
        • 哪个更快,
        • 可以使用更多的有效位
        • 并产生分布相当的结果

    为了说明如何自己研究这个问题,我编写了一些代码来展示最坏情况(哈希冲突)行为:

    import java.util.HashMap;
    
    public class TestHashMapCollisions {
    
        static class C {
            private final String mName;
    
            public C(final String pName) {
                mName = pName;
            }
    
            @Override public int hashCode() {
                return 1;
            }
            @Override public boolean equals(final Object obj) {
                if (this == obj) return true;
                if (obj == null) return false;
                if (getClass() != obj.getClass()) return false;
                final C other = (C) obj;
                if (mName == null) {
                    if (other.mName != null) return false;
                } else if (!mName.equals(other.mName)) return false;
                return true;
            }
        }
    
    
        public static void main(final String[] args) {
            final HashMap<C, Long> testMap = new HashMap<>();
            for (int i = 0; i < 5; i++) {
                final String name = "name" + i;
                final C c = new C(name);
                final Long value = Long.valueOf(i);
                testMap.put(c, value);
            }
    
            final C c = new C("name2");
            System.out.println("Result: " + testMap.get(c));
            System.out.println("End.");
        }
    }
    

    程序:

    • 使用 IDE
    • 将您正在使用的 JDR/JRE 的源代码链接到您的 IDE
    • System.out.println("Result: " + testMap.get(c));行设置断点
    • 在调试中运行
    • 调试器在断点处停止
    • 现在进入 HashMap 实现
    • HashMap.getNode()(Node&lt;K,V&gt;[] tab; Node&lt;K,V&gt; first, e; int n; K k; )的第一行设置断点
    • 恢复调试;调试器将在HashMap 内停止
    • 现在您可以按照调试器的步骤进行操作

    提示:(您可以立即在 HashMap 中设置断点,但这会导致一些混乱,因为 HashMap 在 JVM 初始化时经常使用,因此您会遇到在你开始测试你的代码之前,先有很多不需要的停止)

    【讨论】: