一层,两个队列


59

背景

几年前,当我还是一名本科生时,我们得到了一项关于摊销分析的作业。我无法解决其中一个问题。我曾在comp.theory中提出过此要求,但没有得到满意的结果。我记得课程中TA坚持了他无法证明的事情,并说他忘记了证明,然后……[你知道吗]。

今天,我回顾了这个问题。我仍然很想知道,所以这里是...

问题

是否可以使用两个队列实现堆栈,以便PUSHPOP操作都在摊销时间O(1)中运行?如果可以,你能告诉我如何吗?

注意:如果我们要实现一个具有两个堆栈的队列(具有相应的操作ENQUEUEDEQUEUE),则情况非常简单。请注意区别。

PS:以上问题不是作业本身。作业不需要任何下限;只是一个实现和运行时间分析。


2
我想您只能使用有限的空间,而不是两个队列(O(1)或O(log n))。这对我来说听起来是不可能的,因为我们没有任何办法可以反转长输入流的顺序。但是,当然,除非能够提出严格的要求,否则这不是证明。
伊藤刚(Tsuyoshi Ito)2010年

@Tsuyoshi:您对有限的空间假设是正确的。是的,那是我对那个(固执的)电讯局长说的话,但他拒绝了:(
Dousti女士,

2
@Tsuyoshi:我认为您通常不需要假设空间是有限的,只需要假设您不允许将从堆栈中推入和弹出的对象存储在除两个队列之外的任何其他位置(并且可能恒定数量的变量)。
卡夫

@SadeqDousti在我看来,唯一可行的方法是,如果您使用队列的链表实现,并使用一些指针始终指向“堆栈”的顶部
Charles Addis

2
听起来,TA可能实际上已经想说“使用两个堆栈实施队列”,这确实可以在“ O(1)摊销时间内”实现。
Thomas Ahle 2013年

Answers:


45

我没有实际的答案,但是这里有一些证据表明问题是存在的:

  • 在Ming Li,LucLongpré和Paul MBVitányi(《结构的1986年》中的“队列的力量”)中没有提到,它考虑了其他几个紧密相关的模拟

  • 在Theor的MartinHühne的《论几个队列的力量》中没有提到。比较 科学 1993年,发表后续论文。

  • 在COCOON 2001的Holger Petersen的“ Stacks vs Deques”中没有提到。

  • Burton Rosenberg,“使用两个队列快速无条件地识别上下文无关的语言”,Inform。程序 来吧 1998年,提出了O(n log n)两队列算法,用于使用两个队列识别任何CFL。但是,不确定的下推自动机可以在线性时间内识别CFL。因此,如果模拟一个堆栈的每个队列比每个操作的O(log n)快两个队列,那么Rosenberg和他的裁判应该知道这一点。


4
+1可提供出色的参考。但是,有一些技术方面的问题:与第一篇论文一样,有些论文没有考虑使用两个队列模拟一个堆栈的问题(正如我从摘要中所说的那样)。其他人则考虑最坏情况的分析,而不是摊销成本。
MS Dousti

13

下面的答案是“作弊”,因为尽管操作之间不使用任何空间,但操作本身可以使用空间。请参阅此线程中的其他地方,以获得没有此问题的答案。O(1)

虽然我没有回答您的确切问题,但确实找到了一种算法,该算法可以在时间而不是。我相信这很严格,尽管我没有证据。如果有的话,该算法表明尝试证明的下限是徒劳的,因此它可能有助于回答您的问题。OnOnO(n)O(n)O(n)

我介绍了两种算法,第一种是Pop的运行时间为的简单算法,第二种是Pop的运行时间为的简单算法。我之所以描述第一个,主要是因为它很简单,所以第二个更容易理解。O O(n)O(n)

更详细地说:第一个不使用额外的空间,具有最坏情况(摊销)Push和最坏情况(摊销)Pop,但是最坏情况下的行为并不总是触发。由于它不使用两个队列以外的任何额外空间,因此它比Ross Snider提供的解决方案“略好”。O n O(1)O(n)

第二个使用单个整数字段(因此额外空间),在最坏的情况下(并摊销)推入,在摊销Pop。因此,它的运行时间明显比“简单”方法的运行时间长,但它确实占用了一些额外的空间。O 1 O O(1)O(1)O(n)

第一种算法

我们有两个队列:队列和队列。将是我们的“推送队列”,将是已经处于“堆栈顺序”的队列。小号Ë Ç ö Ñ ð ˚F ř 小号小号È Ç Ò Ñ ðfirstsecondfirstsecond

  • 通过简单地将参数放入即可完成推送。first
  • 弹出操作如下。如果为空,我们只需将队列出队并返回结果。否则,我们反转,将所有附加到然后交换和。然后我们出队,并返回出队的结果。小号Ë Ç ö Ñ d ˚F ř 小号小号Ë Ç ö Ñ d ˚F ř 小号˚F ř 小号小号Ë Ç ö Ñ d 小号È Ç Ò Ñ dfirstsecondfirstsecondfirstfirstsecondsecond

第一种算法的C#代码

即使您以前从未看过C#,这也应该很容易理解。如果您不知道泛型是什么,只需将所有“ T”实例替换为“字符串”,即可获得一堆字符串。

public class Stack<T> {
    private Queue<T> first = new Queue<T>();
    private Queue<T> second = new Queue<T>();
    public void Push(T value) {
        first.Enqueue(value);
    }
    public T Pop() {
        if (first.Count == 0) {
            if (second.Count > 0)
                return second.Dequeue();
            else
                throw new InvalidOperationException("Empty stack.");
        } else {
            int nrOfItemsInFirst = first.Count;
            T[] reverser = new T[nrOfItemsInFirst];

            // Reverse first
            for (int i = 0; i < nrOfItemsInFirst; i++)
                reverser[i] = first.Dequeue();    
            for (int i = nrOfItemsInFirst - 1; i >= 0; i--)
                first.Enqueue(reverser[i]);

            // Append second to first
            while (second.Count > 0)
                first.Enqueue(second.Dequeue());

            // Swap first and second
            Queue<T> temp = first; first = second; second = temp;

            return second.Dequeue();
        }
    }
}

分析

显然,Push在时间内起作用。Pop可能会在和内接触到所有东西恒定的时间,因此在最坏的情况下,我们得到。例如,如果一个将元素压入堆栈,然后依次重复执行单次压入和单个Pop操作,该算法就会表现出这种行为。˚F ř 小号小号Ë Ç ö Ñ d ø Ñ ñO(1)firstsecondO(n)n

第二种算法

我们有两个队列:队列和队列。将是我们的“推送队列”,将是已经处于“堆栈顺序”的队列。小号Ë Ç ö Ñ ð ˚F ř 小号小号È Ç Ò Ñ ðfirstsecondfirstsecond

这是第一种算法的改编版本,在这种算法中,我们不会立即将“ ”的内容“洗牌” 为。相反,如果与相比包含的元素数量足够少(即,中元素数量的平方根),则我们仅将重新组织为堆栈顺序,而不将其与合并。firstsecondfirstsecondsecondfirstsecond

  • 仍然可以通过简单地将参数放入来完成推送。first
  • 弹出操作如下。如果为空,我们只需将队列出队并返回结果。否则,我们将重新组织的内容,以便它们按堆栈顺序排列。如果我们只是简单地出队并返回结果。否则,我们追加到,交换和,出列和返回结果。firstsecondfirst|first|<|second|firstsecondfirstfirstsecondsecond

第一种算法的C#代码

即使您以前从未看过C#,这也应该很容易理解。如果您不知道泛型是什么,只需将所有“ T”实例替换为“字符串”,即可获得一堆字符串。

public class Stack<T> {
    private Queue<T> first = new Queue<T>();
    private Queue<T> second = new Queue<T>();
    int unsortedPart = 0;
    public void Push(T value) {
        unsortedPart++;
        first.Enqueue(value);
    }
    public T Pop() {
        if (first.Count == 0) {
            if (second.Count > 0)
                return second.Dequeue();
            else
                throw new InvalidOperationException("Empty stack.");
        } else {
            int nrOfItemsInFirst = first.Count;
            T[] reverser = new T[nrOfItemsInFirst];

            for (int i = nrOfItemsInFirst - unsortedPart - 1; i >= 0; i--)
                reverser[i] = first.Dequeue();

            for (int i = nrOfItemsInFirst - unsortedPart; i < nrOfItemsInFirst; i++)
                reverser[i] = first.Dequeue();

            for (int i = nrOfItemsInFirst - 1; i >= 0; i--)
                first.Enqueue(reverser[i]);

            unsortedPart = 0;
            if (first.Count * first.Count < second.Count)
                return first.Dequeue();
            else {
                while (second.Count > 0)
                    first.Enqueue(second.Dequeue());

                Queue<T> temp = first; first = second; second = temp;

                return second.Dequeue();
            }
        }
    }
}

分析

显然,Push在时间内起作用。O(1)

Pop在摊销时间内工作。有两种情况:如果,然后我们在时间中将其随机排列为堆栈顺序。如果,那么我们必须至少有对Push的调用。因此,我们只能在每次调用Push和Pop时都遇到这种情况。在这种情况下,实际的运行时间为,因此摊销时间为。O(n)|first|<|second|firstO(|first|)=O(n)|first||second|nnO(n)O(nn)=O(n)

最后说明

通过使Pop 在每次调用时重新组织而不是让Push完成所有工作,可以以使Pop为操作为代价来消除多余的变量。O(n)first


我编辑了第一段,因此我的答案被表述为该问题的实际答案。
亚历克斯10 Brink 2010年

6
您正在使用数组(反向器)进行反向!我认为您不允许这样做。
卡夫

没错,我在执行方法时会使用额外的空间,但我认为这是允许的:如果您想以直接的方式使用两个堆栈来实现一个队列,则必须在某一点上反转一个堆栈,直到我知道您需要额外的空间来执行此操作,因此,由于这个问题类似,我认为只要在方法调用之间不使用额外的空间,就可以在执行方法期间使用额外的空间。
亚历克斯(Alex)10 Brink

6
“如果您想以直接的方式使用两个堆栈来实现一个队列,则必须在某一点上反转其中一个堆栈,据我所知,您需要额外的空间来做到这一点。” ---您不需要。有一种方法可以使Enqueue的摊销成本为3,而Dequeue的摊销成本为1(即O(1)),并具有一个存储单元和两个堆栈。困难的部分实际上是证明,而不是算法的设计。
亚伦·斯特林2010年

考虑了一下之后,我意识到我确实在作弊,而我之前的评论确实是错误的。我找到了一种纠正它的方法:我想出了两种算法,它们的运行时间与上述两种算法的运行时间相同(尽管“推”现在需要很长时间,而“弹出”现在是在恒定的时间内完成的)而根本不占用额外的空间。写下全部答案后,我将发布一个新答案。
Alex 10 Brink 2010年

12

在对我先前的答案发表了一些评论之后,我发现我或多或少地在作弊:在执行Pop方法时,我使用了额外的空间(第二种算法中使用了额外的空间)。O(n)

以下算法在执行Push和Pop的过程中不使用方法之间的任何额外空间,而仅使用额外的空间。Push具有摊销的运行时间,而Pop具有最坏情况(及摊销)的运行时间。O(1)O(n)O(1)

主持人注意:我不能完全确定我做出此单独答案的决定是否正确。我认为我不应该删除原始答案,因为它可能仍然与问题相关。

算法

我们有两个队列:队列和队列。将是我们的“缓存”,将是我们的主要“存储”。两个队列将始终处于“堆栈顺序”。将在堆栈顶部包含元素,将在堆栈底部包含元素。的大小始终最多为平方根。firstsecondfirstsecondfirstsecondfirstsecond

  • 按被“插”在队列的开始参数做如下:我们排队的参数,以,然后出队并重新入队中的其他元素。这样,参数以的开头结束。firstfirstfirst
  • 如果变得比的平方根较大,我们排队的所有元素到逐个然后交换和。这样,的元素(堆栈的顶部)最终位于的头部。firstsecondsecondfirstfirstsecondfirstsecond
  • 弹出是通过出队并返回结果(如果不为空)来完成的,否则,是通过出队并返回结果来完成的。firstfirstsecond

第一种算法的C#代码

即使您以前从未看过C#,该代码也应该可读性强。如果您不知道泛型是什么,只需将所有“ T”实例替换为“字符串”,即可获得一堆字符串。

public class Stack<T> {
    private Queue<T> first = new Queue<T>();
    private Queue<T> second = new Queue<T>();
    public void Push(T value) {
        // I'll explain what's happening in these comments. Assume we pushed
        // integers onto the stack in increasing order: ie, we pushed 1 first,
        // then 2, then 3 and so on.

        // Suppose our queues look like this:
        // first: in 5 6 out
        // second: in 1 2 3 4 out
        // Note they are both in stack order and first contains the top of
        // the stack.

        // Suppose value == 7:
        first.Enqueue(value);
        // first: in 7 5 6 out
        // second: in 1 2 3 4 out

        // We restore the stack order in first:
        for (int i = 0; i < first.Count - 1; i++)
            first.Enqueue(first.Dequeue());
        // first.Enqueue(first.Dequeue()); is executed twice for this example, the 
        // following happens:
        // first: in 6 7 5 out
        // second: in 1 2 3 4 out
        // first: in 5 6 7 out
        // second: in 1 2 3 4 out

        // first exeeded its capacity, so we merge first and second.
        if (first.Count * first.Count > second.Count) {
            while (second.Count > 0)
                first.Enqueue(second.Dequeue());
            // first: in 4 5 6 7 out
            // second: in 1 2 3 out
            // first: in 3 4 5 6 7 out
            // second: in 1 2 out
            // first: in 2 3 4 5 6 7 out
            // second: in 1 out
            // first: in 1 2 3 4 5 6 7 out
            // second: in out

            Queue<T> temp = first; first = second; second = temp;
            // first: in out
            // second: in 1 2 3 4 5 6 7 out
        }
    }
    public T Pop() {
        if (first.Count == 0) {
            if (second.Count > 0)
                return second.Dequeue();
            else
                throw new InvalidOperationException("Empty stack.");
        } else
            return first.Dequeue();
    }
}

分析

显然,在最坏的情况下,Pop在时间内起作用。O(1)

推送在摊销时间内起作用。有两种情况:如果则Push需要时间。如果然后Push需要时间,但是此操作之后将为空。在再次得到这种情况之前,将需要时间,因此摊销时间为时间。O(n)|first|<|second|O(n)|first||second|O(n)firstO(n)O(nn)=O(n)


关于删除答案,请查看meta.cstheory.stackexchange.com/q/386/873
MS Dousti

我听不懂这句话first.Enqueue(first.Dequeue())。你打错了什么吗?
MS Dousti

感谢您提供的链接,我相应地更新了原始答案。其次,我在代码中添加了很多注释,描述了算法执行过程中发生的事情,希望它能消除任何混乱。
Alex 10 Brink 2010年

对我来说,算法在编辑之前更具可读性,更易于理解。
卡夫

9

我声称我们有摊销成本。亚历克斯的算法给出了上限。为了证明下限,我给出了PUSH和POP移动的最坏情况序列。Θ(N)

最坏的情况包括 PUSH操作,然后是 PUSH操作和 POP操作,再接着是 PUSH操作和 POP操作,等等。是:NNNNN

PUSHN(PUSHNPOPN)N

考虑初始 PUSH操作后的情况。无论算法如何工作,至少一个队列中必须至少有个条目。NN/2

现在考虑处理(第一组) PUSH和POP操作的任务。任何算法策略都必须属于以下两种情况之一:N

在第一种情况下,该算法将使用两个队列。这些队列中的较大队列中至少有个条目,因此我们必须承担至少队列操作的开销,才能最终检索甚至我们入队的单个元素,以后又需要从此较大队列中出队。N/2N/2

在第二种情况下,该算法不使用两个队列。这减少了模拟具有单个队列的堆栈的问题。即使此队列最初是空的,我们也不能做得比使用该队列作为具有顺序访问的循环列表更好,而且,对于每个队列,我们​​平均至少必须使用队列操作的堆栈操作。N/22N

在这两种情况下,我们至少需要次(队列操作)才能处理堆栈操作。因为我们可以重复此过程次,所以总共需要时间来处理堆栈操作,因此每个操作的摊销时间为下限。N/22NNNN/23NΩ(N)


杰克(Jack)对此进行了编辑,因此,回合数(括号中的指数)为而不是我原来的。这是因为我之前向您展示的无法向您摊销整个序列的是“过大杀伤力”,您可以仅从迭代中看到它。谢谢,杰克!NN
肖恩·哈克

这两种情况的混合情况如何?例如,我们将条目交替推入(一个至少具有个条目)和(另一个队列)中?我想这种模式会花费更多,但是如何争论呢?在第二种情况下,我认为堆栈操作中的每一个的平均(摊销)成本至少为。nQ1N/2Q22nn4:1+2++n+n2n
hengxin

显然,彼得的回答与这个下限矛盾吗?
2014年

@Joe我不认为Peter的答案与此下限相矛盾,因为前N次推送从未按此顺序弹出。任何改组过程都将至少需要O(N)个时间,因此如果必须进行每个“阶段”(操作的序列),我们仍然已为该阶段分摊。在我的分析中,这种算法尤其属于“第一种情况”。PUSHNPOPNO(N)
肖恩·哈克

@hengxin您的评论使我意识到我没有像我希望的那样清楚地表达自己的观点。我已经对其进行了编辑,因此现在应该很清楚,第一种情况涵盖了您建议的模式。这种论点是,如果在一种情况下我们甚至将单个元素排队到更大的队列中,我们必须要求操作才能最终检索它。O(N)
肖恩·哈克

6

如果在多次并且没有,当您看到,使用两个队列执行一系列完美的随机播放,则可以得到(分期摊销)的减速。Diaconis,Graham和Cantor于1983年在“完美混洗的数学”中证明,使用完美混洗,可以将“卡片组”重新排序为任何顺序。因此,您可以将一个队列保持为“输入队列”,将一个队列保持为“输出队列”(类似于两个堆栈的情况),然后在请求且输出队列为空时,执行一个完美的序列随机播放以反转输入队列并将其存储在输出队列中。p ü 小号ħ p ö p p ø p ø LG Ñ p Ô pO(lgn)pushpoppopO(lgn)pop

唯一剩下的问题是所需的完美混洗的特定模式是否足够规则以至于不需要超过内存。O(1)

据我所知,这是一个新主意...



啊!我应该寻找更新或相关的问题。您在先前的答案中链接到的论文在k个堆栈和k + 1个堆栈之间建立了关系。这个技巧最终会把k个队列的功能放在k个与k + 1个堆栈之间吗?如果是这样,那将是一个整洁的旁注。无论哪种方式,感谢您将我链接到您的答案,这样我就不会浪费太多时间为另一个场所撰写文章。
Peter Boothe

1

在不使用额外空间的情况下,也许使用优先级队列并强制每个新的推送赋予它比上一个更大的优先级?仍然不会是O(1)。


0

我无法在分摊的固定时间内获得队列来实现堆栈。但是,我可以想到一种在最坏的情况下线性时间获取两个队列来实现堆栈的方法。

  • 使用一个外部数据位,记录最后使用哪个队列(左队列或右队列。AB
  • 每次执行推送操作时,翻转该位,然后将元素划分的队列中的元素插入队列中。从其他队列中弹出所有内容,并将其推送到当前队列中。
  • 弹出操作会从当前队列的最前面移开,并且不会触摸外部状态位。

当然,我们可以添加一些外部状态,该状态可以告诉我们最后的操作是推还是弹出。我们可以将所有内容从一个队列移到另一个队列,直到我们连续执行两次推送操作为止。这也使弹出操作稍微复杂一些。这为弹出操作提供了O(1)摊销的复杂性。不幸的是,推力保持线性。

所有这些工作是因为每次执行一次推送操作时,都会将新元素放在空队列的开头,并将完整队列添加到它的尾端,从而有效地反转了元素。

如果您想获得摊销的固定时间操作,则可能必须做一些更聪明的事情。


4
当然,我可以使用具有相同的更糟的情况下时间复杂度并且没有复杂性的单个队列,从本质上将队列视为一个循环列表,并使用一个额外的队列元素代表堆栈的顶部。
戴夫·克拉克2010年

看起来可以!但是,以这种方式模拟堆栈似乎需要多个经典队列。
罗斯·斯尼德

0

如果您的队列允许前载,那么有一个简单的解决方案,那就是只需要一个队列(或更具体地说,是双端队列)。也许这就是原始问题中的课程TA所考虑的队列类型?

不允许前端加载,这是另一种解决方案:

该算法需要两个队列和两个指针,我们分别将它们称为Q1,Q2,主要和次要。初始化时,Q1和Q2为空,指向Q1的主要点和指向Q2的次要点。

PUSH操作很简单,它仅包含:

*primary.enqueue(value);

POP操作稍微复杂一些;它需要将主队列中除了最后一个队列之外的所有项目都后台处理到辅助队列中,交换指针,并从原始队列中返回最后一个剩余的项目:

while(*primary.size() > 1)
{
    *secondary.enqueue(*primary.dequeue());
}

swap(primary, secondary);
return(*secondary.dequeue());

没有进行边界检查,也不是O(1)。

当我键入此代码时,我发现可以使用for循环代替while循环使用单个队列来完成,就像Alex一样。无论哪种方式,PUSH操作均为O(1),POP操作变为O(n)。


这是另一种使用两个队列和一个指针的解决方案,分别称为Q1,Q2和queue_p:

初始化后,Q1和Q2为空,queue_p指向Q1。

同样,PUSH操作是微不足道的,但确实需要一个额外的步骤将queue_p指向另一个队列:

*queue_p.enqueue(value);
queue_p = (queue_p == &Q1) ? &Q2 : &Q1;

POP操作与以前类似,但是现在有n / 2个项目需要在队列中轮换:

queue_p = (queue_p == &Q1) ? &Q2 : &Q1;
for(i=0, i<(*queue_p.size()-1, i++)
{
    *queue_p.enqueue(*queue_p.dequeue());
}
return(*queue_p.dequeue());

PUSH操作仍为O(1),但现在POP操作为O(n / 2)。

就个人而言,对于这个问题,我更喜欢实现一个单双端队列(双端队列)并在需要时将其称为堆栈的想法。


您的第二种算法有助于理解Alex中涉及更多的一种。
hengxin

0

具有队列的堆栈的最佳仿真每次操作都花费时间,与以下情况无关: -时间是最坏情况还是摊销, -我们模拟一个堆栈或任何固定数量的具有队列的堆栈总, -是操作或最大总数目同时项的数目 -推(但不弹出)需要为或没有。kΘ(n1/k)

n O 1 k
n
O(1)

在一个方向(即上限)上,第个队列的大小为,并且与编号较低的队列一起的大小为最近的每个堆栈中的项目(堆栈中的元素较少时除外;也仅移动而不复制项目)。我们通过移动队列之间的物品,做散装移动以达到维持该约束每件时间移动到队列和到队列。每个项目都由其堆栈的标识和其在堆栈中的高度来注释。如果我们允许推入为(或者如果我们只有一个堆栈并允许摊销弹出时间),则不需。ΘÑ / ķΘÑ / ķÔ 1 + 1 Ö Ñ 1 / ķ- 1 ΘÑ 1 / ķiΘ(ni/k)Θ(ni/k)O(1)i+1O(n1/k)i1Θ(n1/k)

在另一个方向(即下限),我们可以继续添加项目,直到对于,第个最近的项目是从每个包含该项目的队列的末尾离开项目,然后我们要求它并重复。假设这发生得还不够。然后,通常必须将新项目添加到大小为的队列中。为了保持此队列大小,必须以频率将项目移动到另一个队列,该队列的大小通常必须为以允许在移动后足够快地检索项目。通过重复此参数,我们可以根据需要获得队列,它们的总大小为(并且正在增长)。ΩÑ 1 / ķø ñ 1 / ķΩñ 1 / ķø Ñ 2 / ķķ Ô Ñ mmΩ(mn1/k)o(n1/k)Ω(n1/k)o(n2/k)ko(n)

同样,如果必须一次清空一个堆栈(在我们再次开始添加项目之前),我希望最佳摊销性能为(一个堆栈使用两个或多个队列);可以使用(基本上)合并排序来实现此性能。Θ(logn)


-3

通过使用第二个队列,可以使用两个队列来实现堆栈。将项目推入堆栈时,它们将添加到队列的末尾。每次弹出一个项目时,必须将第一个队列的n – 1个元素移至第二个,同时返回其余的项目。公共类QueueStack实现IStack {private IQueue q1 = new Queue(); 专用IQueue q2 = new Queue(); public void push(E e){q1.enqueue(e)// O(1)} public E pop(E e){while(1 <q1.size())// O(n){q2.enqueue( q1.dequeue()); } sw apQueues(); 返回q2.dequeue(); } private void swapQueues(){IQueue Q = q2; q2 = q1;q1 = Q; }}


2
您是否错过了有关摊销时间O(1)的问题?
David Eppstein 2014年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.