将 find-min/find-max 堆栈推广到任意顺序统计?
Posted
技术标签:
【中文标题】将 find-min/find-max 堆栈推广到任意顺序统计?【英文标题】:Generalizing the find-min/find-max stack to arbitrary order statistics? 【发布时间】:2011-10-31 23:14:38 【问题描述】:在 this earlier question 中,OP 要求提供一个类似于堆栈的数据结构,每次在 O(1) 时间内支持以下操作:
Push,在堆栈顶部添加一个新元素, Pop,从堆栈中移除顶部元素, Find-Max,返回(但不删除)堆栈的最大元素,并且 Find-Min,返回(但不删除)堆栈的最小元素。几分钟前,我发现 this related question 要求澄清一个类似的数据结构,它不允许查询最大值和最小值,而是允许堆栈的中间元素被查询。这两种数据结构似乎是支持以下操作的更通用数据结构的特例:
Push,将元素压入栈顶, pop,弹出栈顶,和 Find-Kth,对于在创建结构时确定的固定 k,返回堆栈的第 k 个最大元素。可以通过存储一个堆栈和一个包含前 k 个元素的平衡二叉搜索树来支持所有这些操作,这将使所有这些操作在 O(log k) 时间内运行。我的问题是:是否有可能比这更快地实现上述数据结构?也就是说,我们可以得到所有三个操作的 O(1) 吗?或者也许 O(1) 用于推送和弹出,O(log k) 用于订单统计查找?
【问题讨论】:
问题的固定 k '泛化'不包含原始问题以找到中位数,因为这需要动态 k,其中 k = stacksize/2 这是一个非常好的观点!感谢您提出这个问题。 我不知道是什么问题,如果你能找到 O(1) 或优于 O(log n) 的答案,你可以使用此堆栈对项目列表进行排序,优于O(n log n),你调用 n 次得到第 K 个最大的数。对于 k=1...n 【参考方案1】:使用 Trie 存储您的值。尝试已经具有 O(1) 插入复杂度。你只需要担心两件事,弹出和搜索,但如果你稍微调整一下你的程序,那就很容易了。
插入(推送)时,每个路径都有一个计数器,用于存储插入的元素数量。这将允许每个节点跟踪使用该路径插入了多少元素,即数字表示存储在该路径下的元素数量。这样,当您尝试查找第 k 个元素时,这将是每个路径的简单比较。
对于弹出,您可以拥有一个静态对象,该对象具有指向最后存储对象的链接。该对象可以从根对象访问,因此 O(1)。当然,您需要添加函数来检索最后插入的对象,这意味着新推送的节点必须具有指向先前推送的元素的指针(在推送过程中实现;非常简单,也是 O(1))。您还需要递减计数器,这意味着每个节点都必须有一个指向父节点的指针(也很简单)。
用于查找第 k 个元素(这是最小的第 k 个元素,但查找最大的元素非常相似):当您输入每个节点时,您传入 k 和分支的最小索引(对于根,它将是 0)。然后你对每条路径做一个简单的 if 比较:if (k between minimum index and minimum index + pathCounter), 你输入那个路径,传入 k 和新的最小索引为 (minimum index + sum of all previous pathCounters, 不包括那个你拿了)。我认为这是O(1),因为在一定范围内增加数字数据并不会增加求k的难度。
我希望这会有所帮助,如果有任何不清楚的地方,请告诉我。
【讨论】:
【参考方案2】:如果您将堆栈与一对Fibonacci Heaps 配对会怎样?这可以摊销 O(1) Push 和 FindKth,以及 O(lgN) 删除。
堆栈存储 [value, heapPointer] 对。堆存储堆栈指针。 创建一个 MaxHeap,一个 MinHeap。
推送: 如果 MaxHeap 少于 K 项,则将栈顶插入 MaxHeap; else如果新值小于MaxHeap的顶部,先将DeleteMax的结果插入MinHeap,再将新项插入MaxHeap; 否则将其插入 MinHeap。 O(1)(或 O(lgK),如果需要 DeleteMax)
在 FindKth 上,返回 MaxHeap 的顶部。 O(1)
在 Pop 上,还从弹出项目的堆中执行 Delete(node)。 如果它在 MinHeap 中,你就完成了。 O(lgN) 如果它在 MaxHeap 中,还从 MinHeap 执行 DeleteMin 并将结果插入 MaxHeap。 O(lgK)+O(lgN)+O(1)
更新: 我意识到我把它写成第 K 个最小的,而不是第 K 个最大的。 当新值小于当前的第 K 个最小值时,我也忘记了一步。而那一步 将最坏情况的插入推回到 O(lg K)。对于均匀分布的输入和较小的 K,这可能仍然可以,因为它只会在 K/N 插入时遇到这种情况。
*将 New Idea 移至不同的答案 - 它太大了。
【讨论】:
【参考方案3】:@tophat 是对的——因为这个结构可以用来实现排序,所以它的复杂性不能低于等效的排序算法。那么如何在小于 O(lg N) 的时间内进行排序?使用基数排序。
这是一个使用Binary Trie 的实现。将项目插入二进制 Trie 与执行基数排序本质上是相同的操作。插入和删除 s O(m) 的成本,其中 m 是一个常数:密钥中的位数。找到下一个最大或最小的键也是 O(m),通过按顺序进行深度优先遍历的下一步来完成。
所以一般的想法是使用压入堆栈的值作为 trie 中的键。要存储的数据是该项目在堆栈中的出现次数。对于每个推送的项目:如果它存在于 trie 中,则增加其计数,否则以计数 1 存储它。当您弹出一个项目时,找到它,减少计数,如果计数现在为 0,则将其删除。这两个操作是 O(m)。
要获得 O(1) FindKth,请跟踪 2 个值:第 K 个项目的值,以及第一个 K 项目中有多少个该值的实例。 (例如,对于 K=4 和 [1,2,3,2,0,2] 的堆栈,第 K 个值为 2,“iCount”为 2。) 那么当你push values
当您弹出大于 KthValue 的值时,如果存在更多该值的实例,则增加实例计数,否则执行 FindNext 以获得下一个更大的值。
(如果少于K个则规则不同。这种情况下,您可以简单地跟踪最大插入值。当有K个项目时,最大值将是第K个。)
这是一个 C 实现。它依赖于具有此接口的 BinaryTrie (使用 PineWiki 的示例作为基础构建):
BTrie* BTrieInsert(BTrie* t, Item key, int data);
BTrie* BTrieFind(BTrie* t, Item key);
BTrie* BTrieDelete(BTrie* t, Item key);
BTrie* BTrieNextKey(BTrie* t, Item key);
BTrie* BTriePrevKey(BTrie* t, Item key);
这是推送功能。
void KSStackPush(KStack* ks, Item val)
BTrie* node;
//resize if needed
if (ks->ct == ks->sz) ks->stack = realloc(ks->stack,sizeof(Item)*(ks->sz*=2));
//push val
ks->stack[ks->ct++]=val;
//record count of value instances in trie
node = BTrieFind(ks->trie, val);
if (node) node->data++;
else ks->trie = BTrieInsert(ks->trie, val, 1);
//adjust kth if needed
ksCheckDecreaseKth(ks,val);
这里是跟踪KthValue的助手
//check if inserted val is in set of K
void ksCheckDecreaseKth(KStack* ks, Item val)
//if less than K items, track the max.
if (ks->ct <= ks->K)
if (ks->ct==1) ks->kthValue = val; ks->iCount = 1; //1st item
else if (val == ks->kthValue) ks->iCount++;
else if (val > ks->kthValue) ks->kthValue = val; ks->iCount = 1;
//else if value is one of the K, decrement instance count
else if (val < ks->kthValue && (--ks->iCount<=0))
//if that was only instance in set,
//find the previous value, include all its instances
BTrie* node = BTriePrev(ks->trie, ks->kthValue);
ks->kthValue = node->key;
ks->iCount = node->data;
这里是弹出功能
Item KSStackPop(KStack* ks)
//pop val
Item val = ks->stack[--ks->ct];
//find in trie
BTrie* node = BTrieFind(ks->trie, val);
//decrement count, remove if no more instances
if (--node->data == 0)
ks->trie = BTrieDelete(ks->trie, val);
//adjust kth if needed
ksCheckIncreaseKth(ks,val);
return val;
以及增加KthValue的帮手
//check if removing val causes Kth to increase
void ksCheckIncreaseKth(KStack* ks, Item val)
//if less than K items, track max
if (ks->ct < ks->K)
//if removing the max,
if (val==ks->kthValue)
//find the previous node, and set the instance count.
BTrie* node = BTriePrev(ks->trie, ks->kthValue);
ks->kthValue = node->key;
ks->iCount = node->data;
//if removed val was among the set of K,add a new item
else if (val <= ks->kthValue)
BTrie* node = BTrieFind(ks->trie, ks->kthValue);
//if more instances of kthValue exist, add 1 to set.
if (node && ks->iCount < node->data) ks->iCount++;
//else include 1 instance of next value
else
BTrie* node = BTrieNext(ks->trie, ks->kthValue);
ks->kthValue = node->key;
ks->iCount = 1;
所以对于所有 3 个操作来说,这是 O(1) 的算法。它还可以支持 Median 操作:从 KthValue = 第一个值开始,每当堆栈大小更改 2 时,执行 increaseKth 或 DecreasesKth 操作。缺点是常数很大。只有当 m
【讨论】:
聪明,但如果你要假设数据是整数(我不一定能保证)你最好使用 van Emde Boas 树,它需要 O(log log U) 插入/查找前/删除的时间与二进制树的 O(log U) 相比。 它也可以支持浮点键:参见"Comparing Floating Point Numbers"中的按位比较。它还可以处理字符串——最坏情况下的时间只是成为最长字符串的一个因素。 (但对于任何可变长度字符串比较都是如此。)对于字符串,我推荐 Patricia Trees。 van Emde Boas 树看起来很有趣 - 我想权衡是需要大空间。【参考方案4】:您可以使用 skip list 。 (我首先想到的是链表,但插入是 O(n) 并且阿米特用跳过列表纠正了我。我认为这种数据结构在你的情况下可能非常有趣)
With this data structure, inserting/deleting would take O(ln(k))
and finding the maximum O(1)
我会使用:
一个堆栈,包含您的元素 一个包含跳过列表历史的堆栈(包含 k 个最小元素)(我意识到这是第 K 个最大的元素。但它几乎是相同的问题)
推时(O(ln(k))):
如果元素小于第k个元素,则删除第k个元素(O(ln(k))将其放入LIFO堆(O(1))然后将该元素插入到跳过列表中O(ln(k) )
否则不在skip list里就放堆(O(1))
当你向历史记录添加一个新的跳过列表时,因为这类似于写入时的副本,它不会超过 O(ln(k))
弹出时 (O(1):
你只是从两个堆栈中弹出
获取第 k 个元素 O(1):
总是取列表中的最大元素 (O(1))
所有的ln(k)都是摊销成本。
示例:
我将采用与您相同的示例(在 Stack 上,find-min/find-max 比 O(n) 更有效):
假设我们有一个堆栈并按顺序添加值 2、7、1、8、3 和 9。和 k = 3
我会这样表示:
[number in the stack] [ skip list linked with that number]
首先我推送 2,7 和 1(在少于 k 个元素的列表中查找第 k 个元素是没有意义的)
1 [7,2,1]
7 [7,2,null]
2 [2,null,null]
如果我想要第 k 个元素,我只需要在链表中取最大值:7
现在我推 8,3, 9
在我拥有的堆栈顶部:
8 [7,2,1] since 8 > kth element therefore skip list doesn't change
然后:
3 [3,2,1] since 3 < kth element, the kth element has changed. I first delete 7 who was the previous kth element (O(ln(k))) then insert 3 O(ln(k)) => total O(ln(k))
然后:
9 [3,2,1] since 9 > kth element
这是我得到的堆栈:
9 [3,2,1]
3 [3,2,1]
8 [7,2,1]
1 [7,2,1]
7 [7,2,null]
2 [2,null,null]
找到第k个元素:
I get 3 in O(1)
现在我可以弹出 9 和 3(需要 O(1)):
8 [7,2,1]
1 [7,2,1]
7 [7,2,null]
2 [2,null,null]
找到第 k 个元素:
I get 7 in O(1)
并推入 0(花费 O(ln(k) - 插入)
0 [2,1,0]
8 [7,2,1]
1 [7,2,1]
7 [7,2,null]
2 [2,null,null]
【讨论】:
在跳过列表中,删除/插入是 O(logk)。您仍然需要在每次删除后删除/修改链接。 @amit,你是对的。我认为删除最后一个元素将是 O(1) 但在跳过列表中不是这样,因为我需要修改链接。但是删除仍然是推送的一部分,因此它不会改变已经是 O(ln(k)) 的推送的复杂性。 删除是一个流行,并且将其更改为 O(logk) @amit,在堆栈中,我保留数字并为每个数字保留一个跳过列表。因此,在弹出时,我不会修改任何跳过列表,我只取从上一次推送中计算出来的那个。 (这清楚吗?我不确定我是否在这里表达得很好) 据我所知,有两种可能性:(1) 为所有元素保留 1 个跳过列表,这将导致 O(logk) 推送/弹出,或者 (2) 使用不同的跳过每个元素的列表,这将需要 CLONING 前一个列表,并且会导致 O(k) 推送。【参考方案5】:这是否实际上比您的 log k 实现更快,取决于最常用的操作,我建议使用 O(1) Find-kth 和 Pop 和 O(n) Push 实现,其中 n 是堆栈大小.我也想和 SO 分享这个,因为乍一看它只是一个有趣的数据结构,但甚至可能是合理的。
最好用双向链接堆栈来描述它,或者更容易描述为链接堆栈和双向链接排序列表的混合体。基本上,每个节点维护对其他节点的 4 个引用,堆栈顺序中的下一个和上一个以及元素大小上的排序顺序中的下一个和上一个。这两个链表可以使用相同的节点来实现,但它们完全分开工作,即排序后的链表不必知道堆栈顺序,反之亦然。
与普通的链接堆栈一样,集合本身需要维护对顶部节点(以及底部?)的引用。为了适应 Find-kth 方法的 O(1) 特性,该集合还将保留对第 k 个最大元素的引用。
pop 方法的工作原理如下: 弹出的节点从排序的双向链表中删除,就像从正常的排序链表中删除一样。它需要 O(1) 因为集合有对顶部的引用。根据弹出的元素是大于还是小于第 k 个元素,对第 k 个最大元素的引用设置为前一个或下一个。所以该方法仍然具有 O(1) 复杂度。
push 方法的工作原理就像对排序链表的普通添加一样,这是一个 O(n) 操作。它从最小的元素开始,当遇到更大的元素时插入新节点。为了保持对第 k 个最大元素的正确引用,再次选择当前第 k 个最大元素的前一个或下一个元素,具体取决于推送的节点是大于还是小于第 k 个最大元素。
当然,除此之外,必须在两种方法中设置对堆栈“顶部”的引用。还有k > n的问题,你没有指定数据结构应该做什么。我希望它是如何工作的,否则我可以添加一个示例。
但是,好吧,不完全是您所希望的复杂性,但我发现这是一个有趣的“解决方案”。
编辑:所描述结构的实现
在这个问题上发布了赏金,这表明我的原始答案不够好:P 也许 OP 希望看到一个实现?
我已经在 C# 中实现了中值问题和固定 k 问题。中位数跟踪器的实现只是第 k 个元素的跟踪器的包装,其中 k 可以变异。
回顾一下复杂性:
推送需要 O(n) Pop 耗时 O(1) FindKth 耗时 O(1) 更改 k 需要 O(delta k)我已经在我的原始帖子中合理详细地描述了该算法。实现就相当简单了(但不是那么简单,因为有很多不等式符号和 if 语句需要考虑)。我的评论只是表明 what 做了什么,而不是如何做的细节,否则它会变得太大。 SO帖子的代码已经很长了。
我确实想提供所有重要公共成员的合同:
K
是排序链表中元素的索引,也可以保持引用。它是可变的吗?设置后,结构会立即被纠正。
KthValue
是该索引处的值,除非结构还没有 k 个元素,在这种情况下它返回一个默认值。
HasKthValue
的存在是为了轻松将这些默认值与恰好是其类型的默认值的元素区分开来。
Constructors
:null 可枚举被解释为空可枚举,而 null 比较器被解释为默认值。此比较器定义了确定第 k 个值时使用的顺序。
所以这是代码:
public sealed class KthTrackingStack<T>
private readonly Stack<Node> stack;
private readonly IComparer<T> comparer;
private int k;
private Node smallestNode;
private Node kthNode;
public int K
get return this.k;
set
if (value < 0) throw new ArgumentOutOfRangeException();
for (; k < value; k++)
if (kthNode.NextInOrder == null)
return;
kthNode = kthNode.NextInOrder;
for (; k >= value; k--)
if (kthNode.PreviousInOrder == null)
return;
kthNode = kthNode.PreviousInOrder;
public T KthValue
get return HasKthValue ? kthNode.Value : default(T);
public bool HasKthValue
get return k < Count;
public int Count
get return this.stack.Count;
public KthTrackingStack(int k, IEnumerable<T> initialElements = null, IComparer<T> comparer = null)
if (k < 0) throw new ArgumentOutOfRangeException("k");
this.k = k;
this.comparer = comparer ?? Comparer<T>.Default;
this.stack = new Stack<Node>();
if (initialElements != null)
foreach (T initialElement in initialElements)
this.Push(initialElement);
public void Push(T value)
//just a like a normal sorted linked list should the node before the inserted node be found.
Node nodeBeforeNewNode;
if (smallestNode == null || comparer.Compare(value, smallestNode.Value) < 0)
nodeBeforeNewNode = null;
else
nodeBeforeNewNode = smallestNode;//untested optimization: nodeBeforeNewNode = comparer.Compare(value, kthNode.Value) < 0 ? smallestNode : kthNode;
while (nodeBeforeNewNode.NextInOrder != null && comparerCompare(value, nodeBeforeNewNode.NextInOrder.Value) > 0)
nodeBeforeNewNode = nodeBeforeNewNode.NextInOrder;
//the following code includes the new node in the ordered linked list
Node newNode = new Node
Value = value,
PreviousInOrder = nodeBeforeNewNode,
NextInOrder = nodeBeforeNewNode == null ? smallestNode : nodeBeforeNewNode.NextInOrder
;
if (newNode.NextInOrder != null)
newNode.NextInOrder.PreviousInOrder = newNode;
if (newNode.PreviousInOrder != null)
newNode.PreviousInOrder.NextInOrder = newNode;
else
smallestNode = newNode;
//the following code deals with changes to the kth node due the adding the new node
if (kthNode != null && comparer.Compare(value, kthNode.Value) < 0)
if (HasKthValue)
kthNode = kthNode.PreviousInOrder;
else if (!HasKthValue)
kthNode = newNode;
stack.Push(newNode);
public T Pop()
Node result = stack.Pop();
//the following code deals with changes to the kth node
if (HasKthValue)
if (comparer.Compare(result.Value, kthNode.Value) <= 0)
kthNode = kthNode.NextInOrder;
else if(kthNode.PreviousInOrder != null || Count == 0)
kthNode = kthNode.PreviousInOrder;
//the following code maintains the order in the linked list
if (result.NextInOrder != null)
result.NextInOrder.PreviousInOrder = result.PreviousInOrder;
if (result.PreviousInOrder != null)
result.PreviousInOrder.NextInOrder = result.NextInOrder;
else
smallestNode = result.NextInOrder;
return result.Value;
public T Peek()
return this.stack.Peek().Value;
private sealed class Node
public T Value get; set;
public Node NextInOrder get; internal set;
public Node PreviousInOrder get; internal set;
public class MedianTrackingStack<T>
private readonly KthTrackingStack<T> stack;
public void Push(T value)
stack.Push(value);
stack.K = stack.Count / 2;
public T Pop()
T result = stack.Pop();
stack.K = stack.Count / 2;
return result;
public T Median
get return stack.KthValue;
public MedianTrackingStack(IEnumerable<T> initialElements = null, IComparer<T> comparer = null)
stack = new KthTrackingStack<T>(initialElements == null ? 0 : initialElements.Count()/2, initialElements, comparer);
当然,您随时可以就这段代码提出任何问题,因为我意识到有些事情可能从描述和零星的 cmets 中并不明显
【讨论】:
首先,这是一个很好的解决方案,但是,因为你不能有更多的pop()
s 然后push()
s,O(logn) 解决方案对于pop()
和push()
[如您所建议的那样使用中值缓存],在所有情况下都会更有效。尽管如此,+1
谢谢。你说得对,我自己也应该想到这一点。这使得实现 O(log n) 2x 和 O(1)。我想知道是否会有渐近更快的想法【参考方案6】:
我认为tophat的意思是,实现一个仅支持O(log k) insert和O(1) find-kth(由insert缓存)的纯函数式数据结构,然后将这些结构堆叠起来。 push inserts 到 top 版本并推送更新,pop 弹出 top 版本,find-kth 对 top 版本进行操作。这是 O(log k)/O(1)/(1) 的超线性空间。
编辑:我正在处理 O(1) push/O(1) pop/O(log k) find-kth,我认为它无法完成。上面提到的排序算法可以适应在时间 O(k + (√k) log k) 内得到长度为 k 的数组的 √k 个均匀分布的顺序统计量。问题是,算法必须知道每个订单统计量如何与所有其他元素进行比较(否则可能是错误的),这意味着它已将所有内容分桶到 √k + 1 个桶中,这需要 Ω(k log (√k + 1)) = Ω(k log k) 基于信息论的比较。哎呀。
用 keps 替换 √k 对于任何 eps > 0,使用 O(1) push/O(1) pop,我不认为 find-kth 可以是 O(k1 - eps),即使是随机化和摊销。
【讨论】:
在结构堆栈中,您推送新结构(我的意思是您不只是修改堆栈顶部的先前结构?)?这就是为什么你只看插入而不是删除? @Ricky Bobby Right – 整个历史记录都被存储起来,以便我们可以快速回滚。 我认为这是我在上一篇文章中犯的错误(如果您单击已编辑的 XXX,您将看到上一篇)。但是您的结构将花费 O(k) 来创建大部分时间(当您存储 k 个元素时)。即使插入需要 O(ln(k)) 在插入后克隆历史中的前一个结构。所以它更像是一个 O(k) 推送。 @Ricky Bobby 我们不需要克隆整个结构,因为我们正在做类似于写时复制的事情。 +1 确实有意义。我想我会回到我之前的直觉【参考方案7】:我能想到的唯一实际可行的实现是 Push/Pop O(log k) 和 Kth O(1)。
堆栈(单链接) 最小堆(大小 k) Stack2(双向链接) 值节点将在 Stack、Heap 和 Stack2 之间共享推:
推入堆栈 如果值 >= 堆根 如果堆大小流行:
从堆栈中弹出 如果弹出的节点有 stack2 引用 从 stack2 中移除(双向链表移除) 如果弹出节点有堆引用 从堆中移除(与最后一个元素交换,执行 heap-up-down) 从 stack2 弹出 如果从 stack2 弹出的元素不为 null 将从 stack2 弹出的元素插入堆中KTH:
如果堆大小为 k 返回堆根值【讨论】:
【参考方案8】:由于该结构可用于通过 O(k) push 和 find-kth 操作对 k 个元素进行排序,因此每个基于比较的实现至少有一个成本 Omega(log k),即使在摊销的意义上,与随机化。
推送可以是 O(log k) 并且 pop/find-kth 可以是 O(1)(使用持久数据结构;推送应该预先计算订单统计)。基于使用基于比较的算法的下界,我的直觉是 O(1) push/pop 和 O(log k) find-kth 是可行的,但需要摊销。
【讨论】:
我不相信您可以使用 O(k) 推送和 find-Kth 操作对具有这种结构的 k 个元素进行排序。你能详细说明如何做到这一点吗? 推 k 个元素。第 k 个最大的元素是最小的元素。推一个已知比那里更大的元素。第 k 个最大的元素现在是第二小的。继续推动已知较大的元素,直到您按排序顺序检索所有原始元素。 啊,这是一个漂亮的算法!感谢您发布!我想现在的问题是我们如何才能达到这些界限?以上是关于将 find-min/find-max 堆栈推广到任意顺序统计?的主要内容,如果未能解决你的问题,请参考以下文章
Play 商店 2020:是不是有将应用从封闭测试“推广”到生产的审核流程?