【问题标题】:Why binary search is not possible in sorted linked list?为什么在排序链表中无法进行二分查找?
【发布时间】:2015-03-03 14:38:51
【问题描述】:

是否可以在排序链表中使用二进制搜索来搜索元素? 如果不可能,那么问题是“为什么不可能”?

【问题讨论】:

  • 可以进行二分查找,但是比较麻烦。 (您必须通过遍历列表来访问中间点,这......非常愚蠢。)
  • @William Pursell:一点也不傻,它只是取决于迭代和比较的相对成本。想象一下外部 10 GB 文件的链接列表。
  • @doynax 在这里一针见血。二进制搜索是可能的,甚至可能有用,具体取决于您的应用程序。对于双向链表,二分查找需要 O(n) 遍历步骤和 O(lg n) 比较。相比之下,线性搜索需要 O(n) 的遍历步骤和 O(n) 的比较。有趣的是,您可以证明二分搜索所需的遍历步数总是 >= 线性搜索所需的遍历步数。
  • 如果下面的答案解决了您的问题,请接受 - 请参阅What should I do when someone answers my question?

标签: data-structures


【解决方案1】:

对已排序数组进行二分搜索可以得到 O(log N) 比较和 O(1) 内存利用率的结果。对有序数组进行线性搜索可以得到 O(N) 比较和 O(1) 内存利用率的结果。

除了正常的记忆和比较测量之外,我们还有遍历步骤的想法。这对于没有随机访问的数据结构很重要。例如,在一个链表中,要从头部到达元素 j,我们需要向前走 j 步。这些步骤可以在没有任何比较的情况下发生。正如 cmets 中所指出的,进行遍历步骤的成本可能与进行比较的成本不同。此处的遍历步骤转换为内存读取。

问题是当我们的数据结构是一个排序的单链表时会发生什么?值得做二分查找吗?

为了解决这个问题,我们需要看看二进制搜索在排序单链表上的性能。代码如下所示:

struct Node {
        Node* next;
        int value;
};

Node* binarySearch(Node* n, int v) {
        if (v <= n->value) return n;

        Node *right, *left=n;
        int size = count(n);

        while (size > 1)
        {
                int newSize = (size / 2);

                right = left;
                for (int i = 0; (i < newSize) && (right->next!=nullptr); i++)
                        right = right->next;

                if (v == right->value) return right;
                else if (v > right->value) left = right;

                size -= newSize;
        }

        if (right && (v < right->value)) return right;
        else if (right->next) return right->next;
        else return nullptr;
}

binarySearch 函数返回元素等于或大于v 的节点。参数n是排序单链表中的头节点。

很明显,外部循环迭代 O(log N) 次,其中 N = 列表的大小。对于每次迭代,我们进行 2 次比较,因此总比较次数为 O(log N)。

遍历步数是right = right->next;被执行的次数,也就是O(N)。这是因为内循环中的迭代次数在外循环的每次迭代中都会减少一半,因此 N/2 + N/4 + ... + 1 = N(加上或减去一些回旋余地)。

内存使用量仍为 O(1)。

相比之下,通过排序单链表进行线性搜索需要 O(n) 次遍历步骤、O(n) 次比较和 O(1) 次内存。

那么值得在单链表上进行二分查找吗?答案是几乎总是是的,但不完全是。

不考虑计数成本,如果我们要查找的元素是列表中的第二个元素,会发生什么?线性搜索需要 1 步和 1 次比较。二进制搜索需要〜N个步骤和〜log N个比较。现实不是很清楚。

总结如下:

排序数组

 Binary: O(log N) comparisons, O(1) memory, O(log N) traversal steps
 Linear: O(N) comparisons, O(1) memory, O(N) traversal steps

尽管从技术上讲,排序数组所需的遍历步骤数为 0。我们永远不必前进或后退。这个想法甚至没有意义。

排序单链表

 Binary: O(log N) comparisons, O(1) memory, O(N) traversal steps
 Linear: O(N) comparisons, O(1) memory, O(N) traversal steps

这些是最坏情况下的运行时间。然而,杯子可能并不总是半空:p

【讨论】:

  • 这段 C (C++?) 代码看起来总是像线条噪音,但非常好。
  • 除非您使用的是 RAM 模型,否则这些数组访问不会那么便宜。
  • @dfeuer,这实际上取决于您的具体用例。您可以想象整个列表适合缓存的场景。在这种情况下,遍历速度很快。另一方面,比较是不好的,因为分支会导致错误预测(以及流水线和指令缓存的刷新)和其他惩罚。所以在这种情况下,你肯定想做二进制。
  • 数组也有遍历步骤,只是机制更简单——从指针或索引中添加或减去,而不是取消引用指针。订单分析不计入单个操作的成本,只计入它们的数量。
  • @MarkRansom,数组遍历不需要接触任何数据。我想你是对的,那里有一些东西,这就是我包括这些数字的原因。但是,在大多数现代处理器上,+1、+n 都可以在不到一个时钟周期内发生。列表遍历不是这种情况。就 O(1) 而言,这里的实际常数在一种情况下是 >>>>>> 与另一种情况。
【解决方案2】:

链表只允许顺序访问,因此即使列表已排序,也无法进行二分查找。

编辑: 正如其他人所指出的,二分查找是可能的,但毫无意义。

我们可以在链表中模拟随机访问,但这会很慢并且平均时间复杂度为 O(n),因此二分查找(通常为 O(lgn))将花费 O(nlgn) .

编辑 2:正如@ehang 指出的,如果它是一个双向链表,则二分查找只需 O(n)。在每一步中,我们可以从前一个位置开始,而不是从头/尾开始,所以每次移动的距离都会减半。

如果一定要用链表,最好用线性搜索,复杂度只有O(n),比二分搜索简单。

如果您想同时有效地搜索和插入/删除,您可以使用其他数据结构,例如二叉搜索树。

【讨论】:

  • 不是不可能(你可以简单地用顺序访问来模拟随机访问),只是慢。
  • 其实不会是 O(n log n)。这将是 O(n) 遍历步骤和 O(log n) 比较。事实上,它的上限为 n(+/- 1 或 2)。这是假设双向链表。
  • 请注意,通过比较,线性搜索将是 O(n) 遍历步骤和 O(n) 比较。但是,它更简单,并且 O(n) 遍历步骤的平均常数可能会更小(尽管有人应该在这里计算出数学)。
  • 即使是单链表,也是O(n)个遍历步骤。要获得 O(n) 需要一些簿记,但应该是可能的。
【解决方案3】:

ehang shows 如何在仅 O(1) 额外空间、O(n) 遍历时间和 O(log n) 比较的单链表中执行二进制搜索。我以前不相信这是可能的。为了好玩,我想我会在 Haskell 中实现一个类似的算法:

bs :: Ord k => Int -> k -> [(k,v)] -> Maybe v
bs 0 _ _ = Nothing
bs 1 needle ((k,v) : _)
  | k == needle = Just v
  | otherwise = Nothing
bs size needle left = case drop size' left of
    right@((k,_):_)
      | needle >= k -> bs (size - size') needle right
    _ -> bs size' needle left
  where size' = size `quot` 2

search :: Ord k => k -> [(k,v)] -> Maybe v
search k kvs = bs (length kvs) k kvs

这可以调整为使用 O(log i) 比较和 O(i) 遍历时间,其中 i 是从列表开头到寻找的键所在位置的距离。这个实现可以改进,但要点很简单——用这个版本替换上面的search

import Control.Applicative ((<|>))

search :: Ord k => k -> [(k,v)] -> Maybe v
-- The 10 can be replaced by any positive integer
search = go 10
  where
    -- The 2 can be replaced by any integer > 1
    go lim needle kvs@((k,_):_) | k <= needle =
      bs lim needle kvs <|> go (lim*2) needle (drop lim kvs)
    go _ _ _ = Nothing

【讨论】:

  • +1 for Haskell 就像你的旧答案一样 :-) 我想知道如果我们使用 O(log n) 额外的空间来存储 @ 的头部是否会更有效987654326@s 我们已经来过,所以我们只需要执行n 遍历步骤而不是2n。你怎么看?
  • @Bergi,我洗澡的时候一直在想你的评论,但我不知道你在说哪个头(除了他们肯定是 right 的头) s 而不是 lefts)。
  • 是的,我的意思是你在代码中命名为right 的“中间人”。如果我们向左走(多次),drop 在同一个列表上被多次调用,再次对同一个节点进行不必要的遍历。如果我们可以在遍历到right 时从最左侧分支中这些“中间节点”的头部构建一个列表,我们可以节省一些其他节点遍历。我想如果我们限制该列表的长度,我们仍然可以占用恒定空间,但避免大量 drop 遍历。
  • @Bergi,如果我们保存所有这些,那是 O(n) 额外的。我想你可以找到一种方法来选择其中的 log n 个,但我不确定如何决定。
  • 请注意,在最坏的情况下,您需要执行 n 个遍历步骤,而不是 2n 个。真的没有办法让它变得更好。即使你在空间中用完 O(n)。假设您要查找的元素是最后一个元素。如果不触及其他所有元素,就无法实现它。
猜你喜欢
  • 1970-01-01
  • 2020-09-20
  • 2017-04-09
  • 1970-01-01
  • 2011-07-10
  • 2017-07-09
  • 1970-01-01
  • 2016-07-06
  • 2021-04-20
相关资源
最近更新 更多