什么是最适合存储和管理整数集合的C++数据结构?

18

这是我的第一个StackOverflow问题,如果我没有遵循社区准则或者应该删除它,请让我知道。

我得到了自己的第一道面试题,但是因为我的实现被拒绝了。

问题是:

设计并实现一个C++类,存储整数集合。在构造时,集合应为空。相同的数字可能会被存储多次。

实现以下方法:

  1. Insert(int x)。插入一个值为“x”的条目。

  2. Erase(int x)。从集合中删除一个值为“x”的条目(如果存在)。

  3. Erase(int from, int to)。删除所有值在范围[from,to]中的条目。

  4. Count(int from, int to)。计算具有范围[from,to]中的值的条目数。

我认为一个好的实现方式是使用链表,因为它使用非连续内存,而且删除条目不需要移动大量数据(如向量或数组中的数据)。然而,我收到了公司的反馈,说我的实现时间复杂度为O(n^2),效率很低,所以我被拒绝了。如果在另一个面试中出现类似的问题,我不想重复同样的错误,所以我想知道如何最好地解决这个问题(一个朋友建议使用map,但他也不确定)。

我的代码是:

void IntegerCollector::insert(int x)
{
    entries.push_back(x);
}

void IntegerCollector::erase(int x)
{
    list<int>::iterator position = find(entries.begin(), entries.end(), x);
    if (position != entries.end())
        entries.erase(position);
}

void IntegerCollector::erase(int from, int to)
{
    list<int>::iterator position = entries.begin();

    while (position != entries.end())
    {
        if (*position >= from && *position <= to)
            position = entries.erase(position);
        else
            position++;
    }
}

int IntegerCollector::count(int from, int to)
{
    list<int>::iterator position = entries.begin();
    int count = 0;

    while (position != entries.end())
    {
        if (*position >= from && *position <= to)
            count++;

        position++;
    }

    return count;
}

反馈提到他们只会雇用能够使用O(nlogn)复杂度实现解决方案的候选人。


5
你的算法似乎是O(n),比O(nlogn)更好。 - Ben
1
@ben 是的,但是想象一下,如果他有一个包含n个项目的IntegerCollection,并且他想通过调用每个元素的erase来清除所有内容。这对于每个元素来说都是线性操作,这使得时间复杂度为二次方。 - David G
@NathanOliver 是的,但他并没有说这是被允许的,这就是为什么我更喜欢提醒大家 :-) - bruno
2
这是一个构思良好的问题,但我怀疑它主要基于个人观点。我不确定我们中的任何人都能告诉您面试官认为哪种实现方式是“最佳”的。 - Drew Dormann
1
我的意思是,这里的大O表示可能有误导性,因为这只是一个玩具示例,但我怀疑答案被拒绝是因为它过于天真,而不是因为其本质上是错误的。使用列表来存储整数在内存方面效率低下,并且在现代架构中,这可能会主导大O。 - Ben
显示剩余8条评论
4个回答

22
关键考虑因素是相同值的整数是无法区分的,所以您只需要在容器中存储每个不同值的计数。
然后,您可以使用一个 std::map<int, size_t> 作为支持结构,将每个整数(键)映射到它存在于数据结构中的次数(值 = 计数)。
插入和删除单个元素只需要增加和减少给定键的值(在后一种情况下可能删除),都是O(log(distinct_values_in_container))来查找键。
由于 std::map 是有序的,您可以使用 lower_boundupper_bound 进行二进制搜索,因此查找 [from, to) 范围内的键非常高效(找到范围也是O(log(distinct_values_in_container)))。然后轻松地删除它们或对它们的计数求和(运行时在这里更复杂)。
如果您想获得额外的学分,则需要理解渐近运行时间的限制。请考虑以下几点:

这些渐进时间复杂度在实际使用中的意义取决于使用方式。如果没有重复元素插入,时间复杂度为 O(n),但如果存在大量相同元素(例如每个键都有 O(exp(n)) 个值,则可以获得任意好的时间复杂度(n = 插入数量),此时 O(distinct_values_in_container) = O(log(n)))。在所有涉及整数都相同时,所有操作都是 O(1)

作为被面试者,我还会谈论这些渐进时间复杂度是否在实践中有意义。对于给定应用程序,可能会发现映射的树结构(对于缓存和分支预测器来说具有毒性)输给了一个简单的 std::vector<std::pair<int, size_t>>(如果擦除总是批量进行)甚至是一个 std::vector<size_t>(如果键是“密集”的)。


我认为你的主要错误(也是你被拒绝的原因)是没有意识到没有必要单独存储每个插入的整数。不幸的是,你似乎也错过了保持列表排序的可能性,但我不明白 O(n^2) 是从哪里来的。


11

如果你被聘用的角色不需要任何先前的编程经验,那么仅凭那份代码样本我不会拒绝你。

使用std::list是一个有趣的选择,并且表明你已经考虑过这个问题。你使用C++标准库容器而不是从更低级别开始构建,对我来说是一个肯定录用的标志。使用你的方法,(1)很快,但(2)、(3)和(4)会比较慢。在没有其他信息的情况下,你应该安排事情以使读取(包括查询)数据比写入更快。你的方法恰好相反。但有时候这正是你想要的——例如,在实时测量时,你希望数据转储阶段尽可能快,而不考虑其他任何因素。对于那种应用程序,你的解决方案将难以超越!

保留意见,但绝不是硬性规定:

一个整数并不意味着一个int。在无法澄清的情况下,建立你的类。

template<typename Y> std::map<Y, std::size_t>

其中Y是整数类型。请注意计数器中使用std::size_t。它统计特定的Y出现的次数。

下次请包含一些程序注释。

不要使用using namespace std;。虽然教程用于清晰度,但专业程序员不这样做。


谢谢你的建议。我没有在这里包含注释,但我在提交的代码中包含了它们。我以后不会再使用命名空间std了。你能解释一下你所说的“big hire flag”是什么意思吗?我应该使用结构体来做一个链表吗? - Muhamad Gafar
3
请参见 此答案 以了解为什么使用 using namespace std; 被认为是不好的做法。 - CK.
你能解释一下为什么使用STL容器是正确的吗?在这里似乎是明智的选择。从问题陈述中,除了操作参数之外没有给出任何其他信息,那么为什么要重新发明轮子呢?使用STL容器可以节省公司时间并编写更好的代码。只有在我知道可以在有限的系统上节省资源或使其更快速地用于特定情况下时,我才会重新发明轮子。 - TechnoSam
1
@TechnoSam,它说的是“雇用标志”,这是一件好事而不是坏事。 - Jared Smith
1
确实如此:雇用一个标志是一件好事!很抱歉造成了困惑。 - Bathsheba
在某些情况下,“using namespace std;”是完全可以的。比如在一些非专业程序员Bjarne Stroustrup/Herb Sutter在C++核心指南中所描述的情况下。 - geza

4

您应该使用map<int,size_t>,其中int是值,size_t是计数。

如果需要实现代码,您应选择平衡二叉树以获得'log N'复杂度。

因此,您有以下节点:

struct Node
{
  int key;
  size_t count;
  Node *left, *right; 
  size_t leftNodesCount, rightNodesCount;
};
leftNodesCountrightNodesCount旨在表示平衡程度,因此任何插入和删除操作都会一直影响到根节点。平衡树是指整个树中的leftNodesCountrightNodesCount差别不大(均值差异不超过1,但您可以将容差设置为更高的值,如2或3)。
现在您需要实现InsertDeleteBalance方法。
要平衡平衡树,您应该旋转不平衡的节点。向左旋转意味着用节点的右侧替换节点,并将节点添加到左侧;向右旋转则是相反方向的同一操作。
平衡复杂度也是“log N”。请注意,在插入和删除之后,您应该以某种方式调用平衡以保持树的复杂度约为“log N”。

0

内存管理是C++最重要的部分。事实上,访问连续的内存比非连续的内存快几倍。对于您的问题,向量将比列表更有效。列表的问题是非连续内存分配,这会导致很多缓存未命中。

这就是为什么专家说“尽可能避免使用列表”的原因。


std::vector 对于这个问题可能比 std::list 更好,但其渐进复杂度比现有答案更差。 - Ben Voigt

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