为啥使用数组比使用地图记忆更快?
Posted
技术标签:
【中文标题】为啥使用数组比使用地图记忆更快?【英文标题】:Why is this memoization faster with an array than with a map?为什么使用数组比使用地图记忆更快? 【发布时间】:2021-04-28 01:18:15 【问题描述】:我正在解决 leetcode (#377) 上的组合 sum IV,内容如下: “给定一个包含所有正数且没有重复的整数数组,找出加起来为正整数目标的可能组合数。”
我使用带有记忆数组的自顶向下递归方法在 Java 中解决了这个问题:
public int combinationSum4(int[] nums, int target)
int[] memo = new int[target+1];
for(int i = 1; i < target+1; i++)
memo[i] = -1;
memo[0] = 1;
return topDownCalc(nums, target, memo);
public static int topDownCalc(int[] nums, int target, int[] memo)
if (memo[target] >= 0)
return memo[target];
int tot = 0;
for(int num : nums)
if(target - num >= 0)
tot += topDownCalc(nums, target - num, memo);
memo[target] = tot;
return tot;
然后我认为我通过初始化整个备忘录数组来浪费时间并且可以只使用 Map 代替(这也将节省空间/内存)。于是我将代码改写如下:
public int combinationSum4(int[] nums, int target)
Map<Integer, Integer> memo = new HashMap<Integer, Integer>();
memo.put(0, 1);
return topDownMapCalc(nums, target, memo);
public static int topDownMapCalc(int[] nums, int target, Map<Integer, Integer> memo)
if (memo.containsKey(target))
return memo.get(target);
int tot = 0;
for(int num : nums)
if(target - num >= 0)
tot += topDownMapCalc(nums, target - num, memo);
memo.put(target, tot);
return tot;
不过我很困惑,因为在提交了我的代码的第二个版本之后,Leetcode 说它比第一个代码更慢并且占用的空间更多。 HashMap如何使用更多空间和运行速度比一个数组谁的值都必须初始化并且谁的长度大于HashMaps的大小?
【问题讨论】:
有一个提示:int 和 Integer。您正在使用的地图正在使用自动装箱。您应该尝试使用 Eclipse Collection 中的专用地图(例如):eclipse.org/collections/javadoc/8.0.0/org/eclipse/collections/… 这是一个很好的问题,顺便问一下,你做得很好。积分! 【参考方案1】:然后我发现我初始化整个备忘录数组是在浪费时间
您可以改为存储“答案 + 1”,以便默认值 (0) 现在可以作为“尚未计算”的占位符,并保存该初始化。并不是说它很贵。让我们深入了解缓存页面。
缓存页面
CPU 是复杂的野兽。它们不直接对内存进行操作;不再。他们确实做不到。芯片的计算部分根本没有连接。一点也不。相反,CPU 有缓存,它有固定的大小(例如,64k - 你不能让单个缓存节点精确地保存更多或更少的 64k,然后整个 64k 被认为是某些 64k 的缓存副本主存段)。一个这样的节点称为缓存页面。
CPU 只能对缓存页进行操作。
在 java 中,int[]
导致一个连续的、大小相等的内存块来表示数据。换句话说,int[] x = new int[1000]
将声明一个 1000*4 = 4000 字节的内存块(因为 int 是 4 个字节,而您为 1000 个 em 保留了空间)。这适合单个页面。因此,当您编写循环以将值初始化为 -1 时,这就是要求 CPU 循环遍历单个缓存页面并向其中写入一些数据。 CPU 具有流水线和其他加速因素;这可能需要 250 个周期。
与获取缓存页面的成本相比:CPU 会不停地摆弄它的拇指(这很好;它可以冷却一些,在现代硬件上,CPU 通常不是受限于其原始速度能力,而是受限于系统消除运行时产生的热影响的能力!-它还可以将时间花在其他线程/进程上),同时它将将一些内存块提取到缓存页面中的工作外包给内存控制器。然而,这种拇指旋转需要 500 次或更多周期的数量级。很高兴 CPU 在此期间冷却下来或专注于其他事情,但在紧密循环中写入 4000 个连续字节仍然比单个缓存未命中要快。
因此,“用 -1 填充一个 1000 大的 int 数组”是一个非常便宜的操作。
包装对象
映射作用于对象,而不是整数,这就是为什么你必须写Integer
而不是int
。 Integer
,至少在内存中,对内存的负载要大得多。它是一个完整的对象,包含一个 int 字段。然后,你的变量(或你的地图)持有一个指向它的指针。
所以,int[] x = new int[1000]
需要 4000 个字节,加上对象头的一些更改(可能会全部添加 12 个字节)和 1 个引用(取决于 VM,但假设是 64 位),总计4020 字节。
相比之下,
Integer[] x = new Integer[1000];
for (int i = 0; i < 1000; i++) x[i] = i;`
要大得多。它是 1000 个指针(每个指针可以大到 8 个字节,也可以小到 4 个。因此,4000 到 8000 个字节),到 1000 个单独的整数对象。每个整数对象都获得对象开销(~12 字节或更多),+ 1 个整数字段,通常是字对齐的(因此,64 位,即使它只有 32 位,假设 64 位 VM 在 64 位上运行硬件,这将是任何现代的情况),另外 20000 字节。总计接近 30000 字节。
这大约需要多出 8 倍的内存。
然后考虑记忆数组中的“键”是固有的(它是数组的索引),而在映射中,键需要单独存储,而且情况更糟:映射中的每个 k/v 对占用 至少 12+12+8+8+8+8 字节(2 个对象开销和 2 个 int 字段用于您的键和值 Integer 对象,以及 2 个指向这些映射的指针),56 个字节。与您的 int[]
相比,它在 4 中完成。
这给你一个 56/4 = 14 的比率。
如果您的地图仅包含 14 个数字中的 1 个,则该地图应该与您的数组一样大,因为该地图可以完成您的数组无法做到的事情:该数组必须来自开始吧,地图只需要存储需要的节点。
不过,对于大多数“有趣”的输入,人们仍会假设,该地图的覆盖系数将在 7.14% 以北远,从而导致地图更大。
地图的对象也被涂抹在内存中,这有可能使它们位于多个缓存页面中:大内存负载 + 碎片 = 让 CPU 等待多个缓存页面获取与能够完成所有操作的简单途径一口气完成工作,无需等待缓存未命中。
可以更快吗?
是的,可能 - 但地图占用率达到 10% 或更高,使用地图来节省空间的概念是可疑的。如果您想尝试,则需要专门设计用于保存整数的地图,仅此而已。这些确实存在,例如eclipse collections' IntIntMap
。
但我敢打赌,在这种情况下,简单的数组记忆策略只是赢家,即使您使用 IntIntMap。
【讨论】:
【参考方案2】:我首先想到了这些事情:
-
HashMap 顾名思义,是一个基于散列的映射。因此,无论您在其中放入什么或从中取出什么,它都必须对密钥进行哈希处理,然后根据该哈希找到目标。
put() 操作也不仅仅是在公园里散步——您可以查看here 了解它的作用。绝对不仅仅是数组赋值。
在 java 中它不适用于原语,因此对于每个值,您必须将整数转换为整数,反之亦然。 (正如其他人所指出的,有可用的 int-specialized map 替代方案,但标准库中没有)
aaand 由于您没有初始化它,它可能需要在运行期间在内部多次调整大小 - 哈希图的默认大小仅为 16 - 这肯定比您使用数组进行的一次性初始化更昂贵。 here's what each resizing does。
它还适用于每个内部条目所需的 Entry 对象,并且所有这些对象也占用一些空间,而不仅仅是一个整数数组
所以我认为哈希图不会为您节省空间或时间。为什么会这样?
【讨论】:
如果您想减少地图替代方案中的空间使用,请尝试使用名为“agrona”的包,其中包含原始数据类型地图。 @javadev 一个专门的映射也可以有更适合的散列。但是,在这种情况下,它仍然无法击败数组。可以直接使用索引的数组对于此类问题是高效的记忆。 当然,只是说在使用地图时有一种减少对象创建的替代方法:)以上是关于为啥使用数组比使用地图记忆更快?的主要内容,如果未能解决你的问题,请参考以下文章