如何计算Map<Int,Int>的中位数?

9

对于一个以数字序列中的数字为键,出现次数为值的映射表,如何用Java实现算法来计算中位数?

例如:

1,1,2,2,2,2,3,3,3,4,5,6,6,6,7,7

在地图中:

Map<Int,Int> map = ...
map.put(1,2)
map.put(2,4)
map.put(3,3)
map.put(4,1)
map.put(5,1)
map.put(6,3)
map.put(7,2)

double median = calculateMedian(map);
print(median);

会导致:
> print(median);
3
>

我需要的是一个Java实现的calculateMedian


2
如果这是一项作业,请进行标记。 - danben
这是作业吗?如果是,请标记为作业。 - rsp
@danben:对我来说这不是作业,但我相信对某些人来说可能是。 - Chris
1
换句话说,你是一位老师? :) - BalusC
2
@BalusC:不,我不是,我只是对可能的解决方案感兴趣。几乎我过去提出的每个问题,在写这里的问题时,我已经有了一个解决方案。看看其他人如何解决它通常非常有趣。顺便说一下,这是一个谷歌面试问题,尽管他们用不同的方式提问 ;) - Chris
我正在寻找优化我的解决方案 https://leetcode.com/problems/find-median-from-data-stream/ 并发现了这个问题。 :) - Vitaliy
4个回答

5

使用Guava

Multiset<Integer> values = TreeMultiset.create();
Collections.addAll(values, 1,1,2,2,2,2,3,3,3,4,5,6,6,6,7,7);

现在回答你的问题:

return Iterables.get(values, (values.size() - 1) / 2);

就是这样。(或者检查大小是否为偶数,并平均两个中心值,以更精确地说明。)

如果计数特别大,使用multiset的entrySet并保持运行总和会更快,但通常最简单的方法也可以。


当然,在这个特定的玩具示例中,你最好创建并排序一个ArrayList而不是使用TreeMultiset,但在现实生活中,这可能不太友好于内存。 - Kevin Bourrillion
“而一个地图的例子会是什么样子呢?抱歉,但我知道如何计算简单序列的中位数,因此我不需要框架。” - Chris
1
为什么要使用映射?TreeMultiset 实现内部使用了映射,但向您呈现了更适合您所做的事情的 API。它不是一个“简单序列”,如果您希望它是这样的话,它看起来可能像一个。 - Kevin Bourrillion

5

线性时间

如果你知道数字的总数(在你的情况下,它是16),你可以从地图的开头或结尾开始,一直相加计数,直到达到第round(n/2)个元素,或者在和为偶数的情况下,它等于floor(n/2)和ceil(n/2)两个元素的平均值=中位数

如果你不知道总数,那么你至少需要遍历所有数字一次。

次线性时间

如果你可以决定数据结构并且可以进行预处理,请参见维基百科关于选择算法,你可能会得到更快的次线性算法。如果你知道数据的分布情况,也可以获得次线性时间。

编辑: 因此,在我们假设有一个包含计数的序列的情况下,我们可以:

  • 同时插入key -> count键值对并维护另一张地图 - key -> running_total
  • 这样,你就有了一个结构,通过查看最后一个键的running_total,你将能够得到total_count
  • 并且你将能够进行二分查找,以定位running_total接近total_count/2的元素位置

这将使内存使用量增加一倍,但对于中位数,它将提供O(log n)的性能,对于total_count则为O(1)。


+1 我有时候也会使用这种方法来计算中位数,因为不需要额外的排序。如果你处理的是有界离散值(上限较低),甚至可以进行桶排序(例如,创建一个直方图)。 - zerm
@Rafał,实际上这假设访问一个键是O(1),并且没有太多其他的东西(OP指定键值等于某个范围,我假设没有空洞=>排序);此外,这里重要的是running_total,我只是保持了与OP相同的数据结构。 - Unreason
计算运行总数是否需要通过整个映射进行遍历,如果映射大小是可变的? - thegreatcoder

2
  • 使用SortedMap,即TreeMap
  • 遍历一次地图以计算元素的总数,即所有出现次数的总和
  • 再次遍历并累加出现次数,直到达到总数的一半。导致总和超过一半的数字是中位数
  • 严格测试是否存在差一错误

2
一半的总数?如果你很幸运,一半的总数会让你接近几乎但不完全是平均值的元素。如果你的SortedMap中有'n'个元素,中位数将是在'n/2'处的元素。 - McBeth
1
不错的方法,但需要更多的改进...如果你有一个列表1,2,2,4,4,5,你的算法会根据插入顺序返回2或4,而正确的中位数应该是3。 - kasperjj
@kasperjj:是的,那是一个罕见的特殊情况,如果你真的需要支持它,就需要额外的代码。 - Michael Borgwardt
+1,因为基本上我已经写了同样的东西(我提到了测试奇偶数);另外,由于 OP 谈论序列和计数,你真的需要 SortedMap 吗? - Unreason
@Unreason:啊,但是如果你需要插入一个之前根本不存在的数字呢? - Michael Borgwardt
显示剩余6条评论

1

对于一个简单但可能不太高效的算法,我会这样做:

1. 将地图扩展为列表。

实际上,遍历地图并将键“值-次数”添加到新列表中。最后对列表进行排序。

//...
List<Integer> field = new ArrayList<Integer>();
for (Integer key:map) {
  for (int i = 0; i < map.get(key); i++) {
    field.add(key);
  }
}
Collections.sort(field);

2. 计算中位数

现在你需要实现一个方法int calculateMedian(List<Integer> sorted)。这取决于你需要的中位数类型。如果只是样本中位数,那么结果要么是中间值(对于元素个数为奇数的列表),要么是两个中间值的平均值(对于长度为偶数的列表)。注意,列表需要排序!

(参考:样本中位数 / 维基百科


好的,好的,尽管Chris没有提到效率,这里有一个计算样本中位数的想法(!),而不需要扩展映射...

Set<Integer> sortedKeys = new TreeSet<Integer>(map.keySet()); // just to be sure ;)
Integer median = null;  // Using Integer to have a 'invalid/not found/etc' state
int total = 0;
for (Integer key:sortedKeys) {
  total += map.get(key);
}
if (isOddNumber(total)) { // I don't have to implement everything, do I?
  int counter = total / 2;  // index starting with 0
  for (Integer key:sortedKeys) {
    middleMost -= map.get(key);
    if (counter < 0) {
      // the sample median was in the previous bin
      break;
    }
    median = key;
  }
} else {
  int lower = total/2;
  int upper = lower + 1;
  for (Integer key:sortedKeys) {
    lower -= map.get(key);
    upper -= map.get(key);
    if (lower < 0 && upper < 0) {
      // both middlemost values are in the same bin
      break;
    } else (lower < 0 || upper < 0) {
      // lower is in the previous, upper in the actual bin
      median = (median + key) / 2; // now we need the average
      break;
    }
    median = key;
  }
}

(我手头没有编译器 - 如果有太多的语法错误,请将其视为伪代码;))

-1: 我认为关键是Chris并不想扩展列表,因为这可能会非常低效。 - Michael Borgwardt
我同意Michael的观点,虽然答案很明确,但它只是不必要地扩展了列表,消耗了大量的内存,而提供解决方案的算法却非常简单(因此我根本看不出这样做的理由)。 - Unreason
简单?或许从代码行数角度来看是这样,但基于列表扩展的算法更容易阅读和理解。而且,我们现在有了一个非常专业化的用于特殊地图的算法。中位数计算部分的可重用性接近零。 - Andreas Dolk
关于复杂性,我同意你的方法更简单,但第二种实现并不复杂:两个循环和一些计数。第一种方法的简单性需要更高的内存要求(x是所有计数的平均值;从所讨论的样本数据中,这个值约为2.3,在实际情况中可以是任何整数,假设为100)。此外,如果扩展排序的复杂度增长对数x,循环次数增长x。因此,该算法使用更多的内存,并且速度较慢。 - Unreason
就可重用性而言,考虑到 x 可以是任何整数,让我们假设一些更大的值,比如 100,000;同时想象一些千位数的键。如果您想要重复使用代码,那么这将变得尤为重要,因为它必须适用于您想要重复使用的数据结构的典型领域(在这种情况下,我会说使用 OP 中的映射的典型场景正是当您想要节省存储为列表时占用的空间时,所以参数中相对较大的值并不是罕见情况,而是典型情况)。 - Unreason

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接