普林斯顿算法讲义(二二)

223 阅读1小时+

3.2   二叉搜索树

原文:algs4.cs.princeton.edu/32bst

译者:飞龙

协议:CC BY-NC-SA 4.0

我们研究了一种符号表实现,它将链表中的插入灵活性与有序数组中的搜索效率结合起来。具体来说,每个节点使用两个链接会导致基于二叉搜索树数据结构的高效符号表实现,这被认为是计算机科学中最基本的算法之一。

定义. 二叉搜索树(BST)是一种二叉树,其中每个节点都有一个Comparable键(和一个相关联的值),并满足一个限制条件,即任何节点中的键都大于该节点左子树中所有节点的键,且小于该节点右子树中所有节点的键。

二叉树的解剖              二叉搜索树的解剖

基本实现。

程序 BST.java 使用二叉搜索树实现了有序符号表 API。我们定义一个内部私有类来定义 BST 中的节点。每个节点包含一个��、一个值、一个左��接、一个右链接和一个节点计数。左链接指向具有较小键的项目的 BST,右链接指向具有较大键的项目的 BST。实例变量N给出了根节点下子树中的节点计数。这个字段有助于实现各种有序符号表操作,你将看到。在 BST 中的子树计数

  • 搜索. 一个递归算法用于在 BST 中搜索键,直接遵循递归结构:如果树为空,则搜索未命中;如果搜索键等于根节点的键,则搜索命中。否则,我们在适当的子树中搜索(递归)。递归的get()方法直接实现了这个算法。它以一个节点(子树的根)作为第一个参数,以一个键作为第二个参数,从树的根和搜索键开始。在 BST 中搜索

  • 插入. 插入比搜索实现稍微困难一些。实际上,对于树中不存在的键的搜索会在一个空链接处结束,我们需要做的就是用包含键的新节点替换该链接。递归的put()方法使用了与递归搜索相似的逻辑来完成这个任务:如果树为空,我们返回一个包含键和值的新节点;如果搜索键小于根节点的键,我们将左链接设置为将键插入左子树的结果;否则,我们将右链接设置为将键插入右子树的结果。在 BST 中插入

分析。

算法在二叉搜索树上的运行时间取决于树的形状,而树的形状又取决于键的插入顺序。

BST 的最佳情况              BST 的典型情况               BST 的最坏情况

对于许多应用程序来说,使用以下简单模型是合理的:我们假设键是(均匀)随机的,或者等效地说,它们是以随机顺序插入的。

命题。

在由 N 个随机键构建的 BST 中,搜索命中平均需要约 2 ln N(约 1.39 lg N)次比较。

命题。

在由 N 个随机键构建的 BST 中,插入和搜索未命中平均需要约 2 ln N(约 1.39 lg N)次比较。

下面的可视化展示了以随机顺序向二叉搜索树中插入 255 个键的结果。它显示了键的数量(N)、从根到叶子节点的路径上节点的最大数量(max)、从根到叶子节点的路径上节点的平均数量(avg)、在完全平衡的二叉搜索树中从根到叶子节点的路径上节点的平均数量(opt)。

<media/bst-255random.mov>

您的浏览器不支持视频标签。

基于顺序的方法和删除。

二叉搜索树被广泛使用的一个重要原因是它们可以让我们保持键有序。因此,它们可以作为实现有序符号表 API 中众多方法的基础。

  • 最小值和最大值。 如果根节点的左链接为空,则二叉搜索树中的最小键是根节点的键;如果左链接不为空,则二叉搜索树中的最小键是左链接引用的节点为根的子树中的最小键。查找最大键类似,向右移动而不是向左移动。

  • 下取整和上取整。 如果给定的键 key 小于二叉搜索树根节点的键,则 key 的下取整(小于或等于 key 的二叉搜索树中的最大键)必须在左子树中。如果 key 大于根节点的键,则 key 的下取整可能在右子树中,但只有在右子树中存在小于或等于 key 的键时才可能;如果没有(或者 key 等于根节点的键),则根节点的键就是 key 的下取整。查找上取整类似,交换右子树和左子树。

  • 选择。 假设我们寻找排名为 k 的键(即 BST 中恰好有 k 个其他键比它小)。如果左子树中的键数 t 大于 k,我们在左子树中查找排名为 k 的键;如果 t 等于 k,我们返回根节点的键;如果 t 小于 k,我们在右子树中查找排名为 k - t - 1 的键。

    二叉搜索树中的下取整                   二叉搜索树中的选择
  • 排名。 如果给定的键等于根节点的键,则返回左子树中键数 t;如果给定的键小于根节点的键,则返回左子树中键的排名;如果给定的键大于根节点的键,则返回 t 加一(计算根节点的键)再加上右子树中键的排名。

  • 删除最小值和最大值。 对于删除最小值,我们向左移动直到找到一个具有空左链接的节点,然后用其右链接替换指向该节点的链接。对于删除最大值,对称方法适用。

  • 删除。 我们可以类似地删除任何只有一个子节点(或没有子节点)的节点,但是如何删除具有两个子节点的节点呢?我们剩下两个链接,但是父节点只有一个位置可以放置它们中的一个。1962 年 T. Hibbard 首次提出的解决这个困境的方法是通过用其后继替换节点 x 来删除节点 x。因为 x 有一个右子节点,其后继是其右子树中具有最小键的节点。替换保持了树中的顺序,因为在 x.key 和后继的键之间没有其他键。我们通过四个(!)简单的步骤完成了用其后继替换 x 的任务:

    • t 中保存要删除的节点的链接

    • x 设置为其后继 min(t.right)

    • x 的右链接(应指向包含所有大于 x.key 的键的二叉搜索树)设置为 deleteMin(t.right),即删除后包含所有大于 x.key 的键的二叉搜索树的链接。

    • x 的左链接(原本为空)设置为 t.left(所有小于被删除键和其后继的键)。

    删除二叉查找树中的最小值                   二叉查找树中的 Hibbard 删除

    尽管这种方法能够完成任务,但它有一个缺点,在某些实际情况下可能会导致性能问题。问题在于使用后继者是任意的,而不是对称的。为什么不使用前任者呢?

    每个二叉查找树包含 150 个节点。然后我们通过 Hibbard 删除方法重复删除和随机插入键。二叉查找树向左倾斜。

    <media/hibbard-150random.mov>

    您的浏览器不支持视频标签。

    • 范围搜索。 为了实现返回给定范围内键的keys()方法,我们从一个基本的递归二叉查找树遍历方法开始,称为中序遍历。为了说明这种方法,我们考虑按顺序打印二叉查找树中所有键的任务。为此,首先打印左子树中的所有键(根据二叉查找树的定义,这些键小于根键),然后打印根键,然后打印右子树中的所有键(根据二叉查找树的定义,这些键大于根键)。

    • private void print(Node x) {
         if (x == null) return;
         print(x.left);
         StdOut.println(x.key);
         print(x.right);
      }
      
      

      要实现带有两个参数的keys()方法,我们修改这段代码,将在范围内的每个键添加到一个Queue中,并跳过不能包含范围内键的子树的递归调用。

建议。

搜索、插入、查找最小值、查找最大值、floor、ceiling、rank、select、删除最小值、删除最大值、删除和范围计数操作在最坏情况下都需要时间与树的高度成比例。

练习

  1. 给出五种键A X C S E R H的排序方式,当插入到一个初始为空的二叉查找树时,产生最佳情况的树。

    解决方案。 任何首先插入 H;在 A 和 E 之前插入 C;在 R 和 X 之前插入 S 的序列。

  2. 在 BST.java 中添加一个计算树高度的方法height()。开发两种实现:一个递归方法(需要与树高成比例的线性时间和空间),以及像size()那样为树中的每个节点添加一个字段的方法(需要线性空间和每次查询的常数时间)。

  3. 为了测试文本中给出的min()max()floor()ceiling()select()rank()deleteMin()deleteMax()keys()的实现,编写一个测试客户端 TestBST.java。

  4. 给出二叉查找树的get()put()keys()的非递归实现。

    *解决方案:*NonrecursiveBST.java

创意问题

  1. 完美平衡。 编写一个程序 PerfectBalance.java,将一组键插入到一个初始为空的二叉查找树中,使得生成的树等同于二叉搜索,即对于二叉查找树中任何键的搜索所做的比较序列与二叉搜索对相同键集的比较序列相同。

    提示:将中位数放在根节点,并递归构建左子树和右子树。

  2. 认证。 在 BST.java 中编写一个名为isBST()的方法,该方法以一个Node作为参数,并在参数节点是二叉查找树根节点时返回true,否则返回false

  3. 子树计数检查。 在 BST.java 中编写一个递归方法isSizeConsistent(),该方法以一个Node作为参数,并在该节点根的数据结构中N字段一致时返回true,否则返回false

  4. 选择/排名检查。 在 BST.java 中编写一个名为isRankConsistent()的方法,检查对于所有i0size() - 1,是否i等于rank(select(i)),以及对于二叉查找树中的所有键,是否key等于select(rank(key))

Web 练习

  1. 伟大的树-列表递归问题。 二叉搜索树和循环双向链表在概念上都是由相同类型的节点构建的 - 一个数据字段和两个指向其他节点的引用。 给定一个二叉搜索树,重新排列引用,使其成为一个循环双向链表(按排序顺序)。 尼克·帕兰特将其描述为有史以来设计的最整洁的递归指针问题之一提示:从左子树创建一个循环链接列表 A,从右子树创建一个循环链接列表 B,并使根节点成为一个节点的循环链接列表。 然后合并这三个列表。

  2. BST 重建。 给定 BST 的前序遍历(不包括空节点),重建树。

  3. 真或假。 给定 BST,设 x 是叶节点,y 是其父节点。 那么 y 的键要么是大于 x 的键中最小的键,要么是小于 x 的键中最大的键。 答案:真。

  4. 真或假。 设 x 是 BST 节点。 可以通过沿着树向根遍历直到遇到具有非空右子树的节点(可能是 x 本身);然后在右子树中找到最小键来找到 x 的下一个最大键(x 的后继)。

  5. 具有恒定额外内存的树遍历。 描述如何使用恒定额外内存(例如,没有函数调用堆栈)执行中序树遍历。

    提示:在树下行的过程中,使子节点指向父节点(并在树上行的过程中反转它)。

  6. 反转 BST。 给定一个标准 BST(其中每个键都大于其左子树中的键,小于其右子树中的键),设计一个线性时间算法将其转换为反转 BST(其中每个键都小于其左子树中的键,大于其右子树中的键)。 结果树形状应对称于原始形状。

  7. BST 的层序遍历重建。 给定一系列键,设计一个线性时间算法来确定它是否是某个 BST 的层序遍历(并构造 BST 本身)。

  8. 在 BST 中查找两个交换的键。 给定一个 BST,其中两个节点中的两个键已被交换,找到这两个键。

    解决方案。 考虑 BST 的中序遍历 a[]。 有两种情况需要考虑。 假设只有一个索引 p,使得 a[p] > a[p+1]。 然后交换键 a[p]和 a[p+1]。 否则,存在两个索引 p 和 q,使得 a[p] > a[p+1]和 a[q] > a[q+1]。 假设 p < q。 然后,交换键 a[p]和 a[q+1]。

3.3   平衡搜索树

原文:algs4.cs.princeton.edu/33balanced

译者:飞龙

协议:CC BY-NC-SA 4.0

本节正在大力施工中。

我们在本节介绍了一种类型的二叉搜索树,其中成本保证为对数。我们的树几乎完美平衡,高度保证不会大于 2 lg N。

2-3 搜索树。

获得我们需要保证搜索树平衡的灵活性的主要步骤是允许我们树中的节点保存多个键。

定义。

一个2-3 搜索树是一棵树,要么为空,要么:

  • 一个2 节点,带有一个键(和相关值)和两个链接,一个指向具有较小键的 2-3 搜索树的左链接,一个指向具有较大键的 2-3 搜索树的右链接

  • 一个3 节点,带有两个键(和相关值)和三个链接,一个指向具有较小键的 2-3 搜索树的左链接,一个指向具有节点键之间的键的 2-3 搜索树的中间链接,一个指向具有较大键的 2-3 搜索树的右链接。

2-3 树的解剖图一个完美平衡的 2-3 搜索树(或简称 2-3 树)是指其空链接与根之间的距离都相同。

  • 搜索。 要确定 2-3 树中是否存在一个键,我们将其与根处的键进行比较:如果它等于其中任何一个键,则有一个搜索命中;否则,我们跟随从根到对应于可能包含搜索键的键值区间的子树的链接,然后在该子树中递归搜索。在 2-3 树中搜索

  • 插入到 2 节点中。 要在 2-3 树中插入新节点,我们可能会进行一次不成功的搜索,然后挂接到底部的节点,就像我们在二叉搜索树中所做的那样,但新树不会保持完美平衡。如果搜索终止的节点是一个 2 节点,要保持完美平衡很容易:我们只需用包含其键和要插入的新键的 3 节点替换该节点。在 2-3 树中插入到 2 节点中

  • 插入到由单个 3 节点组成的树中。 假设我们想要插入到一个仅由单个 3 节点组成的微小 2-3 树中。这样的树有两个键,但在其一个节点中没有新键的空间。为了能够执行插入操作,我们暂时将新键放入一个4 节点中,这是我们节点类型的自然扩展,具有三个键和四个链接。创建 4 节点很方便,因为很容易将其转换为由三个 2 节点组成的 2-3 树,其中一个带有中间键(在根处),一个带有三个键中最小的键(由根的左链接指向),一个带有三个键中最大的键(由根的右链接指向)。插入到由单个 3 节点组成的 2-3 树中

  • 插入到父节点为 2 节点的 3 节点中。 假设搜索在底部结束于其父节点为 2 节点的 3 节点。在这种情况下,我们仍然可以为新键腾出空间,同时保持树的完美平衡,方法是制作一个临时的 4 节点,然后按照刚才描述的方式拆分 4 节点,但是,而不是创建一个新节点来保存中间键,将中间键移动到节点的父节点。在父节点为 2 节点的 3 节点中插入到 2-3 树中

  • 插入到父节点为 3 节点的 3 节点中。 现在假设搜索结束于父节点为 3 节点的节点。同样,我们制作一个临时的 4 节点,然后将其拆分并将其中间键插入父节点。父节点是 3 节点,所以我们用刚刚拆分的临时新 4 节点替换它,其中包含来自 4 节点拆分的中间键。然后,我们对该节点执行完全相同的转换。也就是说,我们拆分新的 4 节点并将其中间键插入其父节点。扩展到一般情况很明显:我们沿着树向上移动,拆分 4 节点并将它们的中间键插入它们的父节点,直到达到一个 2 节点,我们用一个不需要进一步拆分的 3 节点替换它,或者直到达到根节点处的 3 节点。在父节点为 3 节点的 2-3 树中插入 3 节点

  • 拆分根节点。 如果从插入点到根节点沿着整个路径都是 3 节点,我们最终会在根节点处得到一个临时的 4 节点。在这种情况下,我们将临时的 4 节点拆分为三个 2 节点。在 2-3 树中拆分根节点

  • 局部转换。 2-3 树插入算法的基础是所有这些转换都是纯粹局部的:除了指定的节点和链接之外,不需要检查或修改 2-3 树的任何部分。每次转换更改的链接数量受到小常数的限制。这些转换中的每一个都将一个键从 4 节点传递到树中的父节点,然后相应地重构链接,而不触及树的任何其他部分。

  • 全局属性。 这些局部转换保持了树是有序和平衡的全局属性:从根到任何空链接的路径上的链接数量是相同的。

命题。

在具有 N 个键的 2-3 树中,搜索和插入操作保证最多访问 lg N 个节点。从随机键构建的典型 2-3 树

然而,我们只完成了实现的一部分。虽然可以编写代码来对表示 2 和 3 节点的不同数据类型执行转换,但我们描述的大部分任务在这种直接表示中实现起来很不方便。

红黑 BST。

刚刚描述的 2-3 树插入算法并不难理解。我们考虑一种简单的表示法,称为红黑 BST,可以自然地实现。

  • 编码 3 节点。 红黑 BST 背后的基本思想是通过从标准 BST(由 2 节点组成)开始,并添加额外信息来编码 3 节点,从而对 2-3 树进行编码。我们认为链接有两种不同类型:红色链接,将两个 2 节点绑在一起表示 3 节点,以及黑色链接,将 2-3 树绑在一起。具体来说,我们将 3 节点表示为由单个向左倾斜的红色链接连接的两个 2 节点。我们将以这种方式表示 2-3 树的 BST 称为红黑 BST。

    使用这种表示的一个优点是,它允许我们在不修改的情况下使用我们的get()代码进行标准 BST 搜索。

    在红黑 BST 中编码 3 节点

  • 1-1 对应关系。 给定任何 2-3 树,我们可以立即推导出相应的红黑 BST,只需按照指定的方式转换每个节点即可。反之,如果我们在红黑 BST 中水平绘制红色链接,所有空链接距离根节点的距离相同,然后将由红色链接连接的节点合并在一起,结果就是一个 2-3 树。左倾红黑 BST 之间的 1-1 对应关系

    红黑 BST 和 2-3 树](../Images/2a82ce5ba078c8217adc45ad5e5d7a47.png)

  • 颜色表示。 由于每个节点只被一个链接(从其父节点)指向,我们通过在节点中添加一个boolean实例变量颜色来编码链接的颜色,如果来自父节点的链接是红色,则为true,如果是黑色,则为false。按照惯例,空链接为黑色。红黑 BST 中的颜色表示

  • 旋转。 我们将考虑的实现可能允许右倾斜的红链接或操作中连续两个红链接,但它总是在完成之前纠正这些条件,通过巧妙使用称为旋转的操作来切换红链接的方向。首先,假设我们有一个需要旋转以向左倾斜的右倾斜红链接。这个操作称为左旋转。实现将左倾斜的红链接转换为右倾斜的右旋转操作等同于相同的代码,左右互换。

  • 翻转颜色。 我们将考虑的实现也可能允许黑色父节点有两个红色子节点。颜色翻转操作将两个红色子节点的颜色翻转为黑色,并将黑色父节点的颜色翻转为红色。

    红黑 BST 中的左旋转              红黑 BST 中的右旋转               红黑 BST 中的颜色翻转
  • 插入到单个 2 节点中。

  • 在底部插入到 2 节点。

  • 在具有两个键的树中(在 3 节点中)插入。

  • 保持根节点为黑色。

  • 在底部插入到 3 节点。

  • 将红链接向上传递树。

实现。

程序 RedBlackBST.java 实现了一个左倾斜的红黑 BST。程序 RedBlackLiteBST.java 是一个更简单的版本,只实现了 put、get 和 contains。红黑 BST 构造

删除。

命题。

具有 N 个节点的红黑 BST 的高度不超过 2 lg N。

命题。

在红黑 BST 中,以下操作在最坏情况下需要对数时间:搜索、插入、查找最小值、查找最大值、floor、ceiling、rank、select、删除最小值、删除最大值、删除和范围计数。

属性。

具有 N 个节点的红黑 BST 中从根到节点的平均路径长度约为~1.00 lg N。

可视化。

以下可视化展示了 255 个键按随机顺序插入到红黑 BST 中。

练习

  1. 哪些是合法的平衡红黑 BST?合法的平衡红黑 BST

    解决方案。 (iii) 和 (iv)。 (i) 不平衡,(ii) 不是对称顺序或平衡的。

  2. 真或假:如果您将键按递增顺序插入到红黑 BST 中,则树的高度是单调递增的。

    解决方案。 真的,请看下一个问题。

  3. 描述当按升序插入键构建红黑 BST 时,插入字母AK时产生的红黑 BST。然后,描述当按升序插入键构建红黑 BST 时通常会发生什么。

    解决方案。 以下可视化展示了 255 个键按升序插入到红黑 BST 中。

  4. 回答前两个问题,当键按降序插入时的情况。

    解决方案。 错误。以下可视化展示了 255 个键按降序插入到红黑 BST 中。

  5. 创建一个测试客���端 TestRedBlackBST.java。

创造性问题

  1. 认证. 在 RedBlackBST.java 中添加一个方法is23(),以检查没有节点连接到两个红链接,并且没有右倾斜的红链接。 添加一个方法isBalanced(),以检查从根到空链接的所有路径是否具有相同数量的黑链接。 将这些方法与isBST()结合起来创建一个方法isRedBlackBST(),用于检查树是否是 BST,并且满足这两个条件。

  2. 旋转的基本定理. 证明任何 BST 都可以通过一系列左旋和右旋转变换为具有相同键集的任何其他 BST。

    解决方案概述: 将第一个 BST 中最小的键旋转到根节点沿着向左的脊柱;然后对结果的右子树进行递归,直到得到高度为 N 的树(每个左链接都为 null)。 对第二个 BST 执行相同的操作。 备注:目前尚不清楚是否存在一种多项式时间算法,可以确定将一个 BST 转换为另一个 BST 所需的最小旋转次数(即使对于至少有 11 个节点的 BST,旋转距离最多为 2N - 6)。

  3. 删除最小值. 通过保持与文本中给出的向树的左脊柱下移的转换的对应关系,同时保持当前节点不是 2 节点的不变性,为 RedBlackBST.java 实现deleteMin()操作。

  4. 删除最大值. 为 RedBlackBST.java 实现deleteMax()操作。 请注意,涉及的转换与前一个练习中的转换略有不同,因为红链接是向左倾斜的。

  5. 删除. 为 RedBlackBST.java 实现delete()操作,将前两个练习的方法与 BST 的delete()操作结合起来。

网络练习

  1. 给定一个排序的键序列,描述如何在线性时间内构建包含这些键的红黑 BST。

  2. 假设在红黑 BST 中进行搜索,在从根节点开始跟踪 20 个链接后终止,以下划线填写下面关于任何不成功搜索的最佳(整数)界限,您可以从这个事实中推断出来

    • 从根节点至少要遵循 ______ 条链接

    • 从根节点最多需要遵循 _______ 条链接

  3. 使用每个节点 1 位,我们可以表示 2、3 和 4 节点。 我们需要多少位来表示 5、6、7 和 8 节点。

  4. 子串反转. 给定长度为 N 的字符串,支持以下操作:select(i) = 获取第 i 个字符,并且 reverse(i, j) = 反转从 i 到 j 的子串。

    解决方案概述. 在平衡搜索树中维护字符串,其中每个节点记录子树计数和一个反转位(如果从根到节点的路径上存在奇数个反转位,则交换左右子节点的角色)。 要实现 select(i),从根节点开始进行二分搜索,使用子树计数和反转位。 要实现 reverse(i, j),在 select(i)和 select(j)处拆分 BST 以形成三个 BST,反转中间 BST 的位,并使用连接操作将它们重新组合在一起。 旋转时维护子树计数和反转位。

  5. BST 的内存. BST、RedBlackBST 和 TreeMap 的内存使用情况是多少?

    解决方案. MemoryOfBSTs.java.

  6. 随机化 BST. 程序 RandomizedBST.java 实现了一个随机化 BST,包括删除操作。 每次操作的预期 O(log N)性能。 期望仅取决于算法中的随机性; 它不依赖于输入分布。 必须在每个节点中存储子树计数字段; 每次插入生成 O(log N)个随机数。

    命题. 树具有与按随机顺序插入键时相同的分布。

  7. 连接. 编写一个函数,该函数以两个随机化 BST 作为输入,并返回包含两个 BST 中元素并集的第三个随机化 BST。 假设没有重复项。

  8. 伸展 BST。 程序 SplayBST.java 实现了一个伸展树

  9. 随机队列。 实现一个 RandomizedQueue.java,使得所有操作在最坏情况下都需要对数时间。

  10. 具有许多更新的红黑色 BST。 当在红黑色 BST 中执行具有已经存在的键的put()时,我们的 RedBlackBST.java 会执行许多不必要的isRed()size()调用。优化代码,以便在这种情况下跳过这些调用。

3.4   哈希表

原文:algs4.cs.princeton.edu/34hash

译者:飞龙

协议:CC BY-NC-SA 4.0

如果键是小整数,我们可以使用数组来实现符号表,通过将键解释为数组索引,以便我们可以将与键 i 关联的值存储在数组位置 i 中。在本节中,我们考虑哈希,这是一种处理更复杂类型键的简单方法的扩展。我们通过进行算术运算将键转换为数组索引来引用键值对。

哈希的关键

使用哈希的搜索算法由两个独立部分组成。第一步是计算哈希函数,将搜索键转换为数组索引。理想情况下,不同的键将映射到不同的索引。这种理想通常超出我们的能力范围,因此我们必须面对两个或更多不同键可能哈希到相同数组索引的可能性。因此,哈希搜索的第二部分是处理这种情况的冲突解决过程。模块化哈希

哈希函数。

如果我们有一个可以容纳 M 个键值对的数组,则需要一个函数,可以将任何给定的键转换为该数组的索引:在范围[0, M-1]内的整数。我们寻求一个既易于计算又均匀分布键的哈希函数。

  • 典型例子。 假设我们有一个应用程序,其中键是美国社会安全号码。例如,社会安全号码 123-45-6789 是一个分为三个字段的 9 位数。第一个字段标识发放号码的地理区域(例如,第一个字段为 035 的号码来自罗德岛,第一个字段为 214 的号码来自马里兰),其他两个字段标识个人。有十亿个不同的社会安全号码,但假设我们的应用程序只需要处理几百个键,因此我们可以使用大小为 M = 1000 的哈希表。实现哈希函数的一种可能方法是使用键中的三位数。使用右侧字段中的三位数可能比使用左侧字段中的三位数更可取(因为客户可能不均匀地分布在地理区域上),但更好的方法是使用所有九位数制成一个整数值,然后考虑下面描述的整数的哈希函数。

  • 正整数。 用于哈希整数的最常用方法称为模块化哈希:我们选择数组大小 M 为素数,并且对于任何正整数键 k,计算 k 除以 M 的余数。这个函数非常容易计算(在 Java 中为 k % M),并且在 0 和 M-1 之间有效地分散键。

  • 浮点数。 如果键是介于 0 和 1 之间的实数,我们可能只需乘以 M 并四舍五入到最接近的整数以获得 0 和 M-1 之间的索引。尽管这是直观的,但这种方法有缺陷,因为它给予键的最高有效位更多权重;最低有效位不起作用。解决这种情况的一种方法是使用键的二进制表示进行模块化哈希(这就是 Java 所做的)。

  • 字符串。 模块化哈希也适用于长键,如字符串:我们只需将它们视为巨大的整数。例如,下面的代码计算了一个 String s 的模块化哈希函数,其中 R 是一个小素数(Java 使用 31)。

    int hash = 0;
    for (int i = 0; i < s.length(); i++)
        hash = (R * hash + s.charAt(i)) % M;
    
    
  • 复合键。 如果键类型具有多个整数字段,我们通常可以像刚才描述的String值一样将它们混合在一起。例如,假设搜索键的类型为 USPhoneNumber.java,其中包含三个整数字段:区域(3 位区号)、交换(3 位交换)和分机(4 位分机)。在这种情况下,我们可以计算数字

    int hash = (((area * R + exch) % M) * R + ext) % M; 
    
    
  • Java 约定。 Java 帮助我们解决了每种数据类型都需要一个哈希函数的基本问题,要求每种数据类型必须实现一个名为hashCode()的方法(返回一个 32 位整数)。对象的hashCode()实现必须与equals一致。也就是说,如果a.equals(b)为真,则a.hashCode()必须与b.hashCode()具有相同的数值。如果hashCode()值相同,则对象可能相等也可能不相等,我们必须使用equals()来确定哪种情况成立。

  • hashCode()转换为数组索引。 由于我们的目标是一个数组索引,而不是 32 位整数,因此我们在实现中将hashCode()与模块化哈希结合起来,以产生 0 到 M-1 之间的整数,如下所示:

    private int hash(Key key) {
       return (key.hashCode() & 0x7fffffff) % M;
    }
    
    

    该代码掩盖了符号位(将 32 位整数转换为 31 位非负整数),然后通过除以 M 来计算余数,就像模块化哈希一样。

  • 用户定义的hashCode() 客户端代码期望hashCode()在可能的 32 位结果值中均匀分散键。也就是说,对于任何对象x,你可以编写x.hashCode(),并且原则上期望以相等的可能性获得 2³² 个可能的 32 位值中的任何一个。Java 为许多常见类型(包括StringIntegerDoubleDateURL)提供了渴望实现此功能的hashCode()实现,但对于您��己的类型,您必须自己尝试。程序 PhoneNumber.java 演示了一种方法:从实例变量中生成整数并使用模块化哈希。程序 Transaction.java 演示了一种更简单的方法:使用实例变量的hashCode()方法将每个转换为 32 位int值,然后进行算术运算。

在为给定数据类型实现良好的哈希函数时,我们有三个主要要求:

  • 它应该是确定性的—相同的键必须产生相同的哈希值。

  • 计算效率应该

  • 它应该均匀分布键

为了分析我们的哈希算法并对其性能提出假设,我们做出以下理想化假设。

假设 J(均匀哈希假设)。

我们使用的哈希函数在 0 和 M-1 之间的整数值之间均匀分布键。

使用分离链接进行哈希。

哈希函数将键转换为数组索引。哈希算法的第二个组成部分是冲突解决:处理两个或更多个要插入的键哈希到相同索引的情况的策略。冲突解决的一个直接方法是为 M 个数组索引中的每一个构建一个键-值对的链表,这些键的哈希值为该索引。基本思想是选择足够大的 M,使得列表足够短,以便通过两步过程进行有效搜索:哈希以找到可能包含键的列表,然后顺序搜索该列表以查找键。使用分离链接进行哈希

程序 SeparateChainingHashST.java 实现了一个带有分离链接哈希表的符号表。它维护了一个 SequentialSearchST 对象的数组,并通过计算哈希函数来选择哪个SequentialSearchST可以包含键,并然后使用SequentialSearchST中的get()put()来完成工作。程序 SeparateChainingLiteHashST.java 类似,但使用了一个显式的Node嵌套类。

命题 K。 在具有 M 个列表和 N 个键的分离链接哈希表中,假设 J 下,列表中键的数量在 N/M 的小常数因子范围内的概率极其接近 1。N/M 的小常数因子范围内的概率极其接近 1。 (假设一个理想的哈希函数。)

这个经典的数学结果很有说服力,但它完全依赖于假设 J。然而,在实践中,相同的行为发生。

性质 L. 在具有 M 个列表和 N 个键的分离链接哈希表中,搜索和插入的比较次数(相等测试)与 N/M 成正比。

使用线性探测进行哈希。

实现哈希的另一种方法是将 N 个键值对存储在大小为 M > N 的哈希表中,依赖表中的空条目来帮助解决冲突。这种方法称为开放寻址哈希方法。最简单的开放寻址方法称为线性探测:当发生冲突(当我们哈希到已经被不同于搜索键的键占据的表索引时),我们只需检查表中的下一个条目(通过增加索引)。有三种可能的结果:

  • 键等于搜索键:搜索命中

  • 空位置(索引位置处的空键):搜索未命中

  • 键不等于搜索键:尝试下一个条目

使用线性探测进行哈希

程序 LinearProbingHashST.java 是使用这种方法实现符号表 ADT 的实现。

与分离链接一样,开放寻址方法的性能取决于比率 α = N/M,但我们对其进行了不同的解释。对于分离链接,α 是每个列表的平均项目数,通常大于 1。对于开放寻址,α 是占用的表位置的百分比;它必须小于 1。我们将 α 称为哈希表的负载因子

命题 M. 在大小为 M 的线性探测哈希表中,N = α M 个键,平均探测次数(在假设 J 下)对于搜索命中约为 ~ 1/2 (1 + 1 / (1 - α)),对于搜索未命中或插入约为 ~ 1/2 (1 + 1 / (1 - α)²)。

问与答。

  1. 为什么 Java 在 StringhashCode() 中使用 31?

  2. 它是质数,因此当用户通过另一个数字取模时,它们没有公共因子(除非它是 31 的倍数)。31 也是梅森素数(如 127 或 8191),是一个比某个 2 的幂少 1 的素数。这意味着如果机器的乘法指令很慢,那么取模可以通过一次移位和一次减法完成。

  3. 如何从类型为double的变量中提取位以用��哈希?

  4. Double.doubleToLongBits(x)返回一个 64 位的long整数,其位表示与doublex的浮点表示相同。

  5. 使用(s.hashCode() % M)Math.abs(s.hashCode()) % M进行哈希到 0 到 M-1 之间的值有什么问题?

  6. 如果第一个参数为负数,则%运算符返回一个非正整数,这将导致数组索引越界错误。令人惊讶的是,绝对值函数甚至可以返回一个负整数。如果其参数为Integer.MIN_VALUE,那么由于生成的正整数无法用 32 位的二进制补码整数表示,这种情况就会发生。这种错误将非常难以追踪,因为它只会发生 40 亿分之一的时间![字符串"polygenelubricants"的哈希码为-2³¹。]

练习

  1. 下面的hashCode()实现是否合法?

    public int hashCode() {
       return 17;
    }
    
    

    解决方案。 是的,但这将导致所有键都哈希到相同的位置,这将导致性能不佳。

  2. 分析使用分离链接、线性探测和二叉搜索树(BSTs)处理double键的空间使用情况。将结果呈现在类似第 476 页上的表中。

    解决方案。

    • 顺序搜索。 24 + 48N. SequentialSearch 符号表中的 Node 占用 48 字节的内存(16 字节开销,8 字节键,8 字节值,8 字节下一个,8 字节内部类开销)。SequentialSearch 对象占用 24 字节(16 字节开销,8 字节第一个)加上节点的内存。

      请注意,booksite 版本每个SequentialSearch对象额外使用 8 字节(4 用于 N,4 用于填充)。

    • 分离链接。 56 + 32M + 48N。SeparateChaining符号表消耗 8M + 56 字节(16 字节开销,20 字节数组开销,8 字节指向数组,每个数组条目的引用 8 字节,4 字节 M,4 字节 N,4 字节填充),再加上 M 个SequentialSearch对象的内存。

创意练习

  1. 哈希攻击。 找到 2^N 个长度为 N 的字符串,它们具有相同的hashCode()值,假设StringhashCode()实现(如Java 标准中指定的)如下:

    public int hashCode() {
       int hash = 0;
       for (int i = 0; i < length(); i++)
          hash = (hash * 31) + charAt(i);
       return hash;
    }
    
    

    解决方案。 很容易验证"Aa""BB"哈希到相同的hashCode()值(2112)。现在,任何由这两个字符串以任何顺序连接在一起形成的长度为 2N 的字符串(例如,BBBBBB,AaAaAa,BBAaBB,AaBBBB)将哈希到相同的值。这里是一个具有相同哈希值的 10000 个字符串的列表。

  2. 糟糕的哈希函数。 考虑以下用于早期 Java 版本的StringhashCode()实现:

    public int hashCode() {
       int hash = 0;
       int skip = Math.max(1, length() / 8);
       for (int i = 0; i < length(); i += skip)
          hash = (hash * 37) + charAt(i);
       return hash;
    }
    
    

    解释为什么你认为设计者选择了这种实现,然后为什么你认为它被放弃,而选择了上一个练习中的实现。

    解决方案。 这样做是希望更快地计算哈希函数。确实,哈希值计算得更快,但很可能许多字符串哈希到相同的值。这导致在许多真实输入(例如,长 URL)上性能显著下降,这些输入都哈希到相同的值,例如,http://www.cs.princeton.edu/algs4/34hash/*****java.html

网络练习

  1. 假设我们希望重复搜索一个长度为 N 的链表,每个元素都包含一个非常长的字符串键。在搜索具有给定键的元素时,我们如何利用哈希值? 解决方案:预先计算列表中每个字符串的哈希值。在搜索键 t 时,将其哈希值与字符串 s 的哈希值进行比较。只有在它们的哈希值相等时才比较字符串 s 和 t。

  2. 为以下数据类型实现hashCode()equals()。要小心,因为很可能许多点的 x、y 和 z 都是小整数。

    public class Point2D {
        private final int x, y;
        ...
    }
    
    

    答案:一个解决方案是使哈希码的前 16 位是 x 的前 16 位和 y 的后 16 位的异或,将哈希码的后 16 位是 x 的后 16 位和 y 的前 16 位的异或。因此,如果 x 和 y 只有 16 位或更少,那么不同点的 hashCode 值将不同。

  3. 以下点的equals()实现有什么问题?

    public boolean equals(Point q) {
        return x == q.x && y == q.y;
    }
    
    

    equals()的错误签名。这是equals()的重载版本,但它没有覆盖从Object继承的版本。这将破坏任何使用PointHashSet的代码。这是更常见的错误之一(与在覆盖equals()时忽略覆盖hashCode()一样)。

  4. 以下代码片段将打印什么?

    import java.util.HashMap;
    import java.util.GregorianCalendar;
    
    HashMap st = new HashMap<gregoriancalendar string="">();
    GregorianCalendar x = new GregorianCalendar(1969, 21, 7);
    GregorianCalendar y = new GregorianCalendar(1969, 4, 12);
    GregorianCalendar z = new GregorianCalendar(1969, 21, 7);
    st.put(x, "human in space");
    x.set(1961, 4, 12);
    System.out.println(st.get(x));
    System.out.println(st.get(y));
    System.out.println(st.get(z));</gregoriancalendar> 
    

    它将打印 false,false,false。日期 7/21/1969 被插入到哈希表中,但在哈希表中的值被更改为 4/12/1961。因此,尽管日期 4/12/1961 在哈希表中,但在搜索 x 或 y 时,我们将在错误的桶中查找,找不到它。我们也找不到 z,因为日期 7/21/1969 不再是哈希表中的键。

    这说明在哈希表的键中只使用不可变类型是一个好习惯。Java 设计者可能应该使GregorianCalendar成为一个不可变对象,以避免出现这样的问题。

  5. 密码检查器。 编写一个程序,从命令行读取一个字符串和从标准输入读取一个单词字典,并检查它是否是一个“好”密码。在这里,假设“好”意味着它(i)至少有 8 个字符长,(ii)不是字典中的一个单词,(iii)不是字典中的一个单词后跟一个数字 0-9(例如,hello5),(iv)不是由一个数字分隔的两个单词(例如,hello2world)。

  6. 反向密码检查器。 修改前一个问题,使得(ii)-(v)也适用于字典中单词的反向(例如,olleh 和 olleh2world)。巧妙的解决方案:将每个单词及其反向插入符号表。

  7. 镜像网站。 使用哈希来确定哪些文件需要更新以镜像网站。

  8. 生日悖论。 假设您的音乐点播播放器随机播放您的 4000 首歌曲(带替换)。您期望等待多久才能听到一首歌曲第二次播放?

  9. 布隆过滤器。 支持插入、存在。通过允许一些误报来使用更少的空间。应用:ISP 缓存网页(特别是大图像、视频);客户端请求 URL;服务器需要快速确定页面是否在缓存中。解决方案:维护一个大小为 N = 8M(M = 要插入的元素数)的位数组。从 0 到 N-1 选择 k 个独立的哈希函数。

  10. CRC-32。 哈希的另一个应用是计算校验和以验证某个数据文件的完整性。要计算字符串 s 的校验和,

    import java.util.zip.CRC32;
    ...
    CRC32 checksum = new CRC32();
    checksum.update(s.getBytes());
    System.out.println(checksum.getValue());
    
    
  11. 完美哈希。 另请参见 GNU 实用程序 gperf。

  12. 密码学安全哈希函数。 SHA-1 和 MD5。可以通过将字符串转换为字节或每次读取一个字节时计算它。程序 OneWay.java 演示了如何使用 java.security.MessageDigest 对象。

  13. 指纹。 哈希函数(例如,MD5 和 SHA-1)也可用于验证文件的完整性。将文件哈希为一个短字符串,将字符串与文件一起传输,如果传输文件的哈希与哈希值不同,则数据已损坏。

  14. 布谷鸟哈希。 在均匀哈希的最大负载下为 log n / log log n。通过选择两者中负载最小的来改进为 log log n。(如果选择 d 中负载最小的,则仅改进为 log log n / log d。)布谷鸟哈希 实现了常数平均插入时间和常数最坏情况搜索:每个项目有两个可能的插槽。如果空,则放入两个可用插槽中的任一个;如果不是,则将另一个插槽中的另一个项目弹出并移动到其另一个插槽中(并递归)。"这个名字来源于一些布谷鸟物种的行为,母鸟将蛋推出另一只鸟的巢来产卵。"如果进入重新定位循环,则重新散列所有内容。

  15. 协变等于。 CovariantPhoneNumber.java 实现了一个协变的 equals() 方法。

  16. 后来者先服务线性探测。 修改 LinearProbingHashST.java,使得每个项目都插入到它到达的位置;如果单元格已经被占用,则该项目向右移动一个条目(规则重复)。

  17. 罗宾汉线性探测。 修改 LinearProbingHashST.java,使得当一个项目探测到已经被占用的单元格时,当前位移较大的项目(两者中的一个)获得单元格,另一个项目向右移动一个条目(规则重复)。

  18. 冷漠图。 给定实线上的 V 个点,其冷漠图是通过为每个点添加一个顶点并在两个顶点之间添加一条边形成的图,当且仅当两个对应点之间的距离严格小于一时。设计一个算法(在均匀哈希假设下),以时间比��于 V + E 计算一组 V 点的冷漠图。

    解决方案. 将每个实数向下取整到最近的整数,并使用哈希表来识别所有向同一整数取整的点。现在,对于每个点 p,使用哈希表找到所有向 p 的取整值内的整数取整的点,并为距离小于一的每对点添加一条边(p, q)。参见此参考链接以了解为什么这需要线性时间。

3.5   搜索应用

原文:algs4.cs.princeton.edu/35applications

译者:飞龙

协议:CC BY-NC-SA 4.0

本节正在大规模施工中。

从计算机的早期时代,当符号表允许程序员从在机器语言中使用数值地址进展到在汇编语言中使用符号名称,到新千年的现代应用,当符号名称在全球计算机网络中具有意义时,快速搜索算法在计算中发挥了并将继续发挥重要作用。符号表的现代应用包括组织科学数据,从在基因组数据中搜索标记或模式到绘制宇宙;在网络上组织知识,从在线商务搜索到将图书馆放在线;以及实现互联网基础设施,从在网络上的机器之间路由数据包到共享文件系统和视频流。

集合 API。

一些符号表客户端不需要值,只需要将键插入表中并测试键是否在表中。由于我们不允许重复键,这些操作对应于以下 API,我们只对表中的键集感兴趣。

为了说明 SET.java 的用途,我们考虑过滤客户端,从标准输入读取一系列键,并将其中一些写入标准输出。

  • *去重。*程序 DeDup.java 从输入流中删除重复项。

  • 白名单和黑名单过滤。另一个经典示例,使用单独的文件中的键来决定哪些来自输入流的键传递到输出流。程序 AllowFilter.java 实现了白名单,其中文件中的任何键都会传递到输出,而文件中没有的键将被忽略。程序 BlockFilter.java 实现了黑名单,其中文件中的任何键都将被忽略,而文件中没有的键将传递到输出。

字典客户端。

最基本的符号表客户端通过连续的put操作构建符号表,以支持get请求。下面列举的熟悉示例说明了这种方法的实用性。典型的字典应用

作为一个具体的例子,我们考虑一个符号表客户端,您可以使用它查找使用逗号分隔值(.csv)文件格式保存的信息。LookupCSV.java 从命令行指定的逗号分隔值文件中构建一组键值对,然后打印出与从标准输入读取的键对应的值。命令行参数是文件名和两个整数,一个指定用作键的字段,另一个指定用作值的字段。

索引客户端。

这个应用是符号表客户端的另一个典型示例。我们有大量数据,想知道感兴趣的字符串出现在哪里。这似乎是将多个值与每个键关联起来,但实际上我们只关联一个SET典型的索引应用FileIndex.java 将一系列文件名作为命令行参数,并构建一个符号表,将每个关键字与可以找到该关键字的文件名的SET关联起来。然后,它从标准输入接受关键字查询。

MovieIndex.java 读取一个包含表演者和电影的数据文件。

稀疏向量和矩阵。

程序 SparseVector.java 使用索引-值对的符号表实现了一个稀疏向量。内存与非零数目成比例。setget操作在最坏情况下需要 log n 时间;计算两个向量的点积所需的时间与两个向量中的非零条目数成比例。

系统符号表。

Java 有几个用于集合和符号表的库函数。API 类似,但你可以将null值插入符号表。

  • TreeMap 库使用红黑树。保证每次插入/搜索/删除的性能为 log N。他们的实现为每个节点维护三个指针(两个子节点和父节点),而我们的实现只存储两个。

  • Sun 在 Java 1.5 中的HashMap实现使用具有分离链接的哈希表。表大小为 2 的幂(而不是素数)。这用 AND 替换了相对昂贵的% M 操作。默认负载因子= 0.75。为防止一些编写不佳的哈希函数,他们对hashCode应用以下混淆例程。

    static int hash(Object x) {
       int h = x.hashCode();
       h += ~(h <<   9);
       h ^=  (h >>> 14);
       h +=  (h <<   4);
       h ^=  (h >>> 10);
       return h;
    }
    
    

Q + A

Q. 运行性能基准测试时,插入、搜索和删除操作的合理比例是多少?

A. 这取决于应用程序。Java 集合框架针对大约 85%的搜索/遍历,14%的插入/更新和 1%的删除进行了优化。

练习

创意练习

  1. 词汇表。 编写一个 ST 客户端 Concordance.java,在标准输出中输出标准输入流中字符串的词汇表。

  2. 稀疏矩阵。 为稀疏 2D 矩阵开发一个 API 和一个实现。支持矩阵加法和矩阵乘法。包括行和列向量的构造函数。

    解决方案:SparseVector.java 和 SparseMatrix.java。

网页练习

  1. 修改FrequencyCount以读取一个文本文件(由 UNICODE 字符组成),并打印出字母表大小(不同字符的数量)和一个按频率降序排序的字符及其频率表。

  2. 集合的交集和并集。 给定两组字符串,编写一个代码片段,计算一个包含这两组中出现的字符串的第三组(或任一组)。

  3. 双向符号表。 支持 put(key, value)和 getByKey(key)或 getByValue(value)。在幕后使用两个符号表。例如:DNS 和反向 DNS。

  4. 突出显示浏览器超链接。 每次访问网站时,保留上次访问网站的时间,这样你只会突出显示那些在过去一个月内访问过的网站。

  5. 频率符号表。 编写一个支持以下操作的抽象数据类型 FrequencyTable.java:hit(Key)count(Key)hit操作将字符串出现的次数增加一。count操作返回给定字符串出现的次数,可能为 0。应用:网页计数器,网页日志分析器,音乐点播机统计每首歌曲播放次数等。

  6. 非重叠区间搜索。 给定一个非重叠整数(或日期)区间列表,编写一个函数,接受一个整数参数,并确定该值位于哪个(如果有)区间中,例如,如果区间为 1643-2033、5532-7643、8999-10332、5666653-5669321,则查询点 9122 位于第三个区间,8122 不在任何区间中。

  7. 注册调度。 一所东北部知名大学的注册处最近安排一名教师在完全相同的时间上教授两门不同的课程。通过描述一种检查此类冲突的方法来帮助注册处避免未来的错误。为简单起见,假设所有课程从 9 点开始,每门课程持续 50 分钟,时间分别为 9、10、11、1、2 或 3。

  8. 列表。 实现以下列表操作:size()、addFront(item)、addBack(item)、delFront(item)、delBack(item)、contains(item)、delete(item)、add(i, item)、delete(i)、iterator()。所有操作应高效(对数时间)。提示:使用两个符号表,一个用于高效查找列表中的第 i 个元素,另一个用于按项目高效搜索。Java 的 List 接口包含这些方法,但没有提供支持所有操作高效的实现。

  9. 间接 PQ。 编写一个实现间接 PQ 的程序 IndirectPQ.java。

  10. LRU 缓存。 创建一个支持以下操作的数据结构:accessremove。访问操作将项目插入到数据结构中(如果尚未存在)。删除操作删除并返回最近访问的项目。提示:在双向链表中按访问顺序维护项目,并在符号表中使用键=项目,值=链表中的位置。当访问一个元素时,从链表中删除它并重新插入到开头。当删除一个元素时,从末尾删除它并从符号表中删除它。

  11. UniQueue. 创建一个数据类型,它是一个队列,但是一个元素只能被插入队列一次。使用存在性符号表来跟踪所有曾经被插入的元素,并忽略重新插入这些项目的请求。

  12. 带随机访问的符号表。 创建一个支持插入键值对、搜索键并返回关联值、删除并返回随机值的数据类型。提示:结合符号表和随机队列。

  13. 纠正拼写错误。 编写一个程序,从标准输入中读取文本,并用建议的替换替换任何常见拼写错误的单词,并将结果打印到标准输出。使用这个常见拼写错误列表(改编自Wikipedia)。

  14. 移至前端。 编码:需要排名查询、删除和插入。解码:需要查找第 i 个、删除和插入。

  15. 可变字符串。 创建一个支持字符串上述操作的数据类型:get(int i)insert(int i, char c)delete(int i),其中get返回字符串的第 i 个字符,insert插入字符 c 并使其成为第 i 个字符,delete删除第 i 个字符。使用二叉搜索树。

    提示:使用 BST(键=0 到 1 之间的实数,值=字符)使得树的中序遍历产生适当顺序的字符。使用select()找到第 i 个元素。在位置 i 插入字符时,选择实数为当前位置 i-1 和 i 的键的平均值。

  16. 幂法和最大特征值。 要计算具有最大幅度的特征值(及相应的特征向量),请使用幂法。在技术条件下(最大两个特征值之间的差距),它会迅速收敛到正确答案。

    • 进行初始猜测 x[1]

    • y[n] = x[n] / ||x[n]||

    • x[n+1] = A y[n]

    • λ = x[n+1]^T y[n]

    • n = n + 1 如果 A 是稀疏的,那么这个算法会利用稀疏性。例如:Google PageRank。

  17. 外积。Vector添加一个方法outer,使得a.outer(b)返回两个长度为 N 的向量 a 和 b 的外积。结果是一个 N×N 矩阵。

  18. 网络链接的幂律分布。(Michael Mitzenmacher)全球网络的入度和出度遵循幂律分布。可以通过优先附加过程来建模。假设每个网页只有一个外链。每个页面逐一创建,从指向自身的单个页面开始。以概率 p < 1,它将链接到现有页面之一,随机选择。以概率 1-p,它将链接到现有页面,概率与该页面的入链数成比例。这一规则反映了新网页倾向于指向热门页面的普遍趋势。编写一个程序来模拟这个过程,并绘制入链数的直方图。

  19. VMAs. Unix 内核中用于管理一组虚拟内存区域(VMAs)的 BST。每个 VMA 代表 Unix 进程中的一部分内存。VMAs 的大小从 4KB 到 1GB 不等。还希望支持范围查询,以确定哪些 VMAs 与给定范围重叠。参考资料

  20. **互联网对等缓存。**由互联网主机发送的每个 IP 数据包都带有一个必须对于该源-目的地对是唯一的 16 位 ID。Linux 内核使用以 IP 地址为索引的 AVL 树。哈希会更快,但希望避免攻击者发送具有最坏情况输入的 IP 数据包。参考资料

  21. 文件索引变体。

    • 移除停用词,例如,a,the,on,of。使用另一个集合来实现。

    • 支持多词查询。这需要进行集合交集操作。如果总是先与最小集合进行交集,那么这将花费与最小集合大小成正比的时间。

    • 实现 OR 或其他布尔逻辑。

    • 记录文档中单词的位置或单词出现的次数。

  22. **算术表达式解释器。**编写一个程序 Interpreter.java 来解析和评估以下形式的表达式。

    >> x := 34
    x := 34.0
    
    >> y := 23 * x    
    y := 782.0
    
    >> z := x ^ y
    z := Infinity
    
    >> z := y ^ 2
    z := 611524.0
    
    >> x
    x := 34.0
    
    >> x := sqrt 2
    x := 1.4142135623730951
    
    

    变体。

    • 添加更复杂的表达式,例如,z = 7 * (x + y * y),使用传统的运算符优先级。

    • 添加更多的错误检查和恢复。

4.   图

原文:algs4.cs.princeton.edu/40graphs

译者:飞龙

协议:CC BY-NC-SA 4.0

概述。

项目之间的成对连接在大量计算应用程序中起着至关重要的作用。这些连接所暗示的关系引发了一系列自然问题:是否有一种方法可以通过遵循这些连接将一个项目连接到另一个项目?有多少其他项目连接到给定项目?这个项目和另一个项目之间的连接的最短链是什么?下表展示了涉及图处理的各种应用程序的多样性。典型的图应用

我们逐步介绍了四种最重要的图模型:无向图(具有简单连接),有向图(其中每个连接的方向很重要),带权重的图(其中每个连接都有一个相关联的权重),以及带权重的有向图(其中每个连接都有方向和权重)。

  • 4.1 无向图介绍了图数据类型,包括深度优先搜索和广度优先搜索。

  • 4.2 有向图介绍了有向图数据类型,包括拓扑排序和强连通分量。

  • 4.3 最小生成树描述了最小生成树问题以及解决它的两种经典算法:Prim 算法和 Kruskal 算法。

  • 4.4 最短路径介绍了最短路径问题以及解决它的两种经典算法:Dijkstra 算法和 Bellman-Ford 算法。

本章中的 Java 程序。

下面是本章中的 Java 程序列表。单击程序名称以访问 Java 代码;单击参考号以获取简要描述;阅读教科书以获取详细讨论。

REF程序描述 / JAVADOC
-Graph.java无向图
-GraphGenerator.java生成随机图
-DepthFirstSearch.java图中的深度优先搜索
-NonrecursiveDFS.java图中的 DFS(非递归)
4.1DepthFirstPaths.java图中的路径(DFS)
4.2BreadthFirstPaths.java图中的路径(BFS)
4.3CC.java图的连通分量
-Bipartite.java二分图或奇环(DFS)
-BipartiteX.java二分图或奇环(BFS)
-Cycle.java图中的环
-EulerianCycle.java图中的欧拉回路
-EulerianPath.java图中的欧拉路径
-SymbolGraph.java符号图
-DegreesOfSeparation.java分离度
-Digraph.java有向图
-DigraphGenerator.java生成随机有向图
4.4DirectedDFS.java有向图中的深度优先搜索
-NonrecursiveDirectedDFS.java有向图中的深度优先搜索(非递归)
-DepthFirstDirectedPaths.java有向图中的路径(深度优先搜索)
-BreadthFirstDirectedPaths.java有向图中的路径(广度优先搜索)
-DirectedCycle.java有向图中的环
-DirectedCycleX.java有向图中的环(非递归)
-DirectedEulerianCycle.java有向图中的欧拉回路
-DirectedEulerianPath.java有向图中的欧拉路径
-DepthFirstOrder.java有向图中的深度优先顺序
4.5Topological.java有向无环图中的拓扑排序
-TopologicalX.java拓扑排序(非递归)
-TransitiveClosure.java传递闭包
-SymbolDigraph.java符号有向图
4.6KosarajuSharirSCC.java强连通分量(Kosaraju–Sharir 算法)
-TarjanSCC.java强连通分量(Tarjan 算法)
-GabowSCC.java强连通分量(Gabow 算法)
-EdgeWeightedGraph.java加权边图
-Edge.java加权边
-LazyPrimMST.java最小生成树(延时 Prim 算法)
4.7PrimMST.java最小生成树(Prim 算法)
4.8KruskalMST.java最小生成树(Kruskal 算法)
-BoruvkaMST.java最小生成树(Boruvka 算法)
-EdgeWeightedDigraph.java加权有向图
-DirectedEdge.java加权有向边
4.9DijkstraSP.java最短路径(Dijkstra 算法)
-DijkstraUndirectedSP.java无向图的最短路径(Dijkstra 算法)
-DijkstraAllPairsSP.java全局最短路径
4.10AcyclicSP.java有向无环图中的最短路径
-AcyclicLP.java有向无环图中的最长路径
-CPM.java关键路径法
4.11BellmanFordSP.java最短路径(贝尔曼-福特算法)
-EdgeWeightedDirectedCycle.java加权有向图中的环
-Arbitrage.java套汇检测
-FloydWarshall.java全局最短路径(稠密图)
-AdjMatrixEdgeWeightedDigraph.java加权图(稠密图)

4.1   无向图

原文:algs4.cs.princeton.edu/41graph

译者:飞龙

协议:CC BY-NC-SA 4.0

图。

是一组顶点和连接一对顶点的的集合。我们在 V-1 个顶点的图中使用 0 到 V-1 的名称表示顶点。图

术语表。

这里是我们使用的一些定义。

  • 自环是连接顶点与自身的边。

  • 如果它们连接相同的一对顶点,则两条边是平行的。

  • 当一条边连接两个顶点时,我们说这两个顶点相邻,并且该边关联这两个顶点。

  • 一个顶点的是与其关联的边的数量。

  • 子图是构成图的边(和相关顶点)的子集,构成一个图。

  • 图中的路径是由边连接的顶点序列,没有重复的边。

  • 一个简单路径是一个没有重复顶点的路径。

  • 循环是一条路径(至少有一条边),其第一个和最后一个顶点相同。

  • 简单循环是一个没有重复顶点(除了第一个和最后一个顶点必须重复)的循环。

  • 一条路径或循环的长度是其边的数量。

  • 如果存在包含它们两者的路径,则我们说一个顶点连接到另一个顶点。

  • 如果从每个顶点到每个其他顶点都存在路径,则图是连通的。

  • 一个非连通的图由一组连通分量组成,这些连通分量是最大连通子图。

  • 无环图是一个没有循环的图。

  • 是一个无环连通图。

  • 森林是一组不相交的树。

  • 连通图的生成树是包含该图所有顶点且为单棵树的子图。图的生成森林是其连通分量的生成树的并集。

  • 二分图是一个我们可以将其顶点分为两组的图,使得所有边连接一组中的顶点与另一组中的顶点。

图的解剖 一棵树 一个生成森林

无向图数据类型。

我们实现以下无向图 API。图 API

关键方法adj()允许客户端代码迭代给定顶点相邻的顶点。值得注意的是,我们可以在adj()所体现的基本抽象上构建本节中考虑的所有算法。

我们准备了测试数据 tinyG.txt、mediumG.txt 和 largeG.txt,使用以下输入文件格式。

图输入格式

图客户端.java 包含典型的图处理代码。

图表示。

我们使用邻接表表示法,其中我们维护一个以顶点索引的数组,数组中的每个元素是与每个顶点通过边连接的顶点的列表。邻接表无向图的表示

图.java 使用邻接表表示法实现了图 API。邻接矩阵图.java 使用邻接矩阵表示法实现了相同的 API。

深度优先搜索。

深度优先搜索是一种经典的递归方法,用于系统地检查图中的每个顶点和边。要访问一个顶点

  • 将其标记为已访问。

  • 访问(递归地)所有与其相邻且尚未标记的���点。

深度优先搜索.java 实现了这种方法和以下 API:搜索 API

寻找路径。

修改深度优先搜索以确定两个给定顶点之间是否存在路径以及找到这样的路径(如果存在)。我们试图实现以下 API:路径 API

为了实现这一点,我们通过将edgeTo[w]设置为v来记住将我们带到每个顶点w的边缘v-w,这是第一次。换句话说,v-w是从源到w的已知路径上的最后一条边。搜索的结果是以源为根的树;edgeTo[]是该树的父链接表示。深度优先路径.java 实现了这种方法。

广度优先搜索。

深度优先搜索找到从源顶点 s 到目标顶点 v 的一条路径。我们经常有兴趣找到最短这样的路径(具有最小数量的边)。广度优先搜索是基于这个目标的经典方法。要从sv找到最短路径,我们从s开始,并在我们可以通过一条边到达的所有顶点中检查v,然后我们在我们可以通过两条边从s到达的所有顶点中检查v,依此类推。

要实现这种策略,我们维护一个队列,其中包含所有已标记但其邻接列表尚未被检查的顶点。我们将源顶点放入队列,然后执行以下步骤,直到队列为空:

  • 从队列中移除下一个顶点v

  • 将所有未标记的与v相邻的顶点放入队列并标记它们。

广度优先路径.java 是实现Paths API 的一个实现,用于找到最短路径。它依赖于 FIFO 队列.java。

连通分量。

我们下一个直接应用深度优先搜索的是找到图的连通分量。回想一下第 1.5 节,“连接到”是将顶点划分为等价类(连通分量)的等价关系。对于这个任务,我们定义以下 API:连通分量 API

CC.java 使用 DFS 实现此 API。

命题。 DFS 在时间上标记与给定源连接的所有顶点,其时间与其度数之和成正比,并为客户提供从给定源到任何标记顶点的路径,其时间与其长度成正比。

**命题。**对于从s可达的任何顶点v,BFS 计算从sv的最短路径(从sv没有更少的边的路径)。在最坏情况下,BFS 花费时间与 V + E 成正比。

命题。 DFS 使用预处理时间和空间与 V + E 成正比,以支持图中的常数时间连接查询。

更多深度优先搜索应用。

我们用 DFS 解决的问题是基础的。深度优先搜索还可以用于解决以下问题:

  • *循环检测:*给定图是否无环?循环.java 使用深度优先搜索来确定图是否有循环,如果有,则返回一个。在最坏情况下,它花费时间与 V + E 成正比。

  • *双色性:*给定图的顶点是否可以被分配为两种颜色,以便没有边连接相同颜色的顶点?二分图.java 使用深度优先搜索来确定图是否具有二��图;如果是,则返回一个;如果不是,则返回一个奇数长度的循环。在最坏情况下,它花费时间与 V + E 成正比。

  • 桥: (或割边)是一条删除后会增加连接组件数量的边。等价地,仅当边不包含在任何循环中时,边才是桥。桥.java 使用深度优先搜索在图中找到桥。在最坏情况下,它花费时间与 V + E 成正比。

  • 双连通性:一个关节点(或割点)是一个移除后会增加连接组件数量的顶点。如果没有关节点,则图形是双连通的。Biconnected.java 使用深度优先搜索来查找桥梁和关节点。在最坏情况下,它的时间复杂度为 V + E。

  • 平面性:如果可以在平面上绘制图形,使得没有边相互交叉,则图形是平面的。 Hopcroft-Tarjan 算法是深度优先搜索的高级应用,它可以在线性时间内确定图形是否是平面的。

符号图。

典型应用涉及使用字符串而不是整数索引来处理图形,以定义和引用顶点。为了适应这些应用程序,我们定义了具有以下属性的输入格式:

  • 顶点名称是字符串。

  • 指定的分隔符分隔顶点名称(以允许名称中包含空格的可能性)。

  • 每行表示一组边,将该行上的第一个顶点名称连接到该行上命名的每个其他顶点。

输入文件 routes.txt 是一个小例子。

航线

输入文件 movies.txt 是来自互联网电影数据库的一个更大的示例。该文件包含列出电影名称后跟电影中表演者列表的行。

电影-表演者图

  • API。 以下 API 允许我们为这种输入文件使用我们的图处理例程。

    符号图 API

  • *实现。*SymbolGraph.java 实现了 API。它构建了三种数据结构:

    • 一个符号表st,具有String键(顶点名称)和int值(索引)

    • 一个作为反向索引的数组keys[],给出与每个整数索引关联的顶点名称

    • 使用索引构建的Graph G,以引用顶点

    符号图数据结构

  • *分离度。*DegreesOfSeparation.java 使用广度优先搜索来查找社交网络中两个个体之间的分离度。对于演员-电影图,它玩的是凯文·贝肯游戏。

练习

  1. 为 Graph.java 创建一个复制构造函数,该构造函数以图G作为输入,并创建并初始化图的新副本。客户端对G所做的任何更改都不应影响新创建的图。

  2. 向 BreadthFirstPaths.java 添加一个distTo()方法,该方法返回从源到给定顶点的最短路径上的边数。distTo()查询应在常数时间内运行。

  3. 编写一个程序 BaconHistogram.java,打印凯文·贝肯号的直方图,指示 movies.txt 中有多少表演者的贝肯号为 0、1、2、3 等。包括那些具有无限号码的类别(与凯文·贝肯没有联系)。

  4. 编写一个SymbolGraph客户端 DegreesOfSeparationDFS.java,该客户端使用深度优先而不是广度优先搜索来查找连接两个表演者的路径。

  5. 使用第 1.4 节的内存成本模型确定Graph表示具有V个顶点和E条边的图所使用的内存量。

    解决方案。 56 + 40V + 128E。MemoryOfGraph.java 根据经验计算,假设没有缓��Integer值—Java 通常会缓存-128 到 127 之间的整数。

创意问题

  1. **并行边检测。**设计一个线性时间算法来计算图中的平行边数。

    提示:维护一个顶点的邻居的布尔数组,并通过仅在需要时重新初始化条目来重复使用此数组。

  2. 双边连通性。 在图中,是一条边,如果移除,则会将一个连通图分隔成两个不相交的子图。没有桥的图被称为双边连通。开发一个基于 DFS 的数据类型 Bridge.java,用于确定给定图是否是边连通的。

网页练习

  1. 找一些有趣的图。它们是有向的还是无向的?稀疏的还是密集的?

  2. 度。 顶点的度是与之关联的边的数量。向Graph添加一个方法int degree(int v),返回顶点 v 的度数。

  3. 假设在运行广度优先搜索时使用堆栈而不是队列。它仍然计算最短路径吗?

  4. 使用显式堆栈的 DFS。 给出 DFS 可能出现堆栈溢出的示例,例如,线图。修改 DepthFirstPaths.java,使其使用显式堆栈而不是函数调用堆栈。

  5. 完美迷宫。 生成一个完美迷宫像这样的

    14×14 完美迷宫22×22 完美迷宫

    编写一个程序 Maze.java,它接受一个命令行参数 n,并生成一个随机的 n×n 完美迷宫。如果迷宫完美,则每对迷宫中的点之间都有一条路径,即没有无法访问的位置,没有循环,也没有开放空间。这里有一个生成这样的迷宫的好算法。考虑一个 n×n 的单元格网格,每个单元格最初与其四个相邻单元格之间都有一堵墙。对于每个单元格(x, y),维护一个变量north[x][y],如果存在将(x, y)和(x, y + 1)分隔的墙,则为true。我们有类似的变量east[x][y]south[x][y]west[x][y]用于相应的墙壁。请注意,如果(x, y)的北面有一堵墙,则north[x][y] = south[x][y+1] = true。通过以下方式拆除一些墙壁来构建迷宫:

    1. 从较低级别单元格(1, 1)开始。

    2. 随机找到一个您尚未到达的邻居。

    3. 如果找到一个,就移动到那里,拆除墙壁。如果找不到,则返回上一个单元格。

    4. 重复步骤 ii.和 iii.,直到您访问了网格中的每个单元格。

    提示:维护一个(n+2)×(n+2)的单元格网格,以避免繁琐的特殊情况。

    这是由卡尔·埃克洛夫使用此算法创建的一个 Mincecraft 迷宫。

    Minecraft 迷宫

  6. 走出迷宫。 给定一个 n×n 的迷宫(就像在前一个练习中创建的那样),编写一个程序,如果存在路径,则从起始单元格(1, 1)到终点单元格(n, n)找到一条路径。要找到迷宫的解决方案,请运行以下算法,从(1, 1)开始,并在到达单元格(n, n)时停止。

    explore(x, y)
    -------------
      - Mark the current cell (x, y) as "visited."
      - If no wall to north and unvisited, then explore(x, y+1).
      - If no wall to east and  unvisited, then explore(x+1, y).
      - If no wall to south and unvisited, then explore(x, y-1).
      - If no wall to west and  unvisited, then explore(x-1, y).
    
    
  7. 迷宫游戏。 开发一个迷宫游戏,就像来自gamesolo.com的这个,您在其中穿过迷宫,收集奖品。

  8. 演员图。 计算凯文·贝肯数的另一种(也许更自然)方法是构建一个图,其中每个节点都是一个演员。如果两个演员一起出现在一部电影中,则它们之间通过一条边连接。通过在演员图上运行 BFS 来计算凯文·贝肯数。比较与文本中描述的算法的运行时间。解释为什么文本中的方法更可取。答案:它避免了多个平行边。因此,它更快,使用的内存更少。此外,它更方便,因为您不必使用电影名称标记边缘-所有名称都存储在顶点中。

  9. 好莱坞宇宙的中心。 我们可以通过计算他们的好莱坞数来衡量凯文·贝肯是一个多好的中心。凯文·贝肯的好莱坞数是所有演员的平均贝肯数。另一位演员的好莱坞数计算方式相同,但我们让他们成为源,而不是凯文·贝肯。计算凯文·贝肯的好莱坞数,并找到一个演员和一个女演员,他们的好莱坞数更好。

  10. 好莱坞宇宙的边缘。 找到(与凯文·贝肯相连的)具有最高好莱坞数的演员。

  11. 单词梯子。 编写一个程序 WordLadder.java,从命令行中获取两个 5 个字母的字符串,并从标准输入中读取一个 5 个字母的单词列表,然后打印出连接这两个字符串的最短单词梯子(如果存在)。如果两个单词在一个字母上不同,那么它们可以在一个单词梯子链中连接起来。例如,以下单词梯子连接了 green 和 brown。

    green greet great groat groan grown brown
    
    

    你也可以尝试在这个 6 个字母单词列表上运行你的程序。

  12. 更快的单词梯子。 为了加快速度(如果单词列表非常大),不要编写嵌套循环来尝试所有成对的单词是否相邻。对于 5 个字母的单词,首先对单词列表进行排序。只有最后一个字母不同的单词将在排序后的列表中连续出现。再排序 4 次,但将字母向右循环移动一个位置,以便在一个排序列表中连续出现在第 i 个字母上不同的单词。

    尝试使用一个更大的单词列表来测试这种方法,其中包含不同长度的单词。如果两个长度不同的单词���有最后一个字母不同,则它们是相邻的。

  13. 假设你删除无向图中的所有桥梁。结果图的连通分量是否是双连通分量?答案:不是,两个双连通分量可以通过一个关节点连接。

    桥梁和关节点。

    桥梁(或割边)是一条移除后会断开图的边。关节点(或割点)是一个移除后(以及移除所有关联边后)会断开剩余图的顶点。桥梁和关节点很重要,因为它们代表网络中的单点故障。蛮力方法:删除边(或顶点)并检查连通性。分别需要 O(E(V + E))和 O(V(V + E))的时间。可以通过巧妙地扩展 DFS 将两者都改进为 O(E + V)。

  14. 双连通分量。 一个无向图是双连通的,如果对于每一对顶点 v 和 w,v 和 w 之间有两条顶点不重叠的路径。(或者等价地,通过任意两个顶点的简单循环。)我们在边上定义一个共圆等价关系:如果 e1 = e2 或者存在包含 e1 和 e2 的循环,则 e1 和 e2 在同一个双连通分量中。两个双连通分量最多共享一个公共顶点。一个顶点是关节点,当且仅当它是多于一个双连通分量的公共部分时。程序 Biconnected.java 标识出桥梁和关节点。

  15. 双连通分量。 修改Biconnected以打印构成每个双连通分量的边。提示:每个桥梁都是自己的双连通分量;要计算其他双连通分量,将每个关节点标记为已访问,然后运行 DFS,跟踪从每个 DFS 起点发现的边。

  16. 对随机无向图的连通分量数量进行数值实验。在 1/2 V ln V 附近发生相变。(参见 Algs Java 中的属性 18.13。)

  17. 流氓。(安德鲁·阿普尔。)在一个无向图中,一个怪物和一个玩家分别位于不同的顶点。在角色扮演游戏 Rogue 中,玩家和怪物轮流行动。每轮中,玩家可以移动到相邻的顶点或原地不动。确定玩家在怪物之前可以到达的所有顶点。假设玩家先行动。

  18. 流氓。(安德鲁·阿普尔。)在一个无向图中,一个怪物和一个玩家分别位于不同的顶点。怪物的目标是落在与玩家相同的顶点上。为怪物设计一个最佳策略。

  19. 关节点。 设 G 是一个连通的无向图。考虑 G 的 DFS 树。证明顶点 v 是 G 的关节点当且仅当(i)v 是 DFS 树的根并且有多于一个子节点,或者(ii)v 不是 DFS 树的根并且对于 v 的某个子节点 w,w 的任何后代(包括 w)和 v 的某个祖先之间没有反向边。换句话说,v 是关节点当且仅当(i)v 是根并且有多于一个子节点,或者(ii)v 有一个子节点 w,使得 low[w] >= pre[v]。

  20. 谢尔宾斯基垫。 一个优美的欧拉图的例子。

  21. 优先连接图。 如下创建一个具有 V 个顶点和 E 条边的随机图:以任意顺序开始具有 V 个顶点 v1,..,vn。均匀随机选择序列的一个元素并添加到序列的末尾。重复 2E 次(使用不断增长的顶点列表)。将最后的 2E 个顶点配对以形成图。

    大致来说,等价于按照两个端点的度数的乘积成比例的概率逐个添加每条边。参考

  22. 维纳指数。 一个顶点的维纳指数是该顶点与所有其他顶点之间的最短路径距离之和。图 G 的维纳指数是所有顶点对之间的最短路径距离之和。被数学化学家使用(顶点=原子,边=键)。

  23. 随机游走。 从迷宫中走出(或图中的 st 连通性)的简单算法:每一步,朝一个随机方向迈出一步。对于完全图,需要 V log V 时间(收集优惠券);对于线图或环,需要 V² 时间(赌徒的失败)。一般来说,覆盖时间最多为 2E(V-1),这是 Aleliunas、Karp、Lipton、Lovasz 和 Rackoff 的经典结果。

  24. 删除顺序。 给定一个连通图,确定一个顺序来删除顶点,使得每次删除后图仍然连通。你的算法在最坏情况下应该花费与 V + E 成比例的时间。

  25. 树的中心。 给定一个树(连通且无环)的图,找到一个顶点,使得它与任何其他顶点的最大距离最小化。

    提示:找到树的直径(两个顶点之间的最长路径)并返回中间的一个顶点。

  26. 树的直径。 给定一个树(连通且无环)的图,找到最长的路径,即一对顶点 v 和 w,它们之间的距离最远。你的算法应该在线性时间内运行。

    提示。 选择任意顶点 v。计算从 v 到每个其他顶点的最短路径。设 w 是最大最短路径距离的顶点。计算从 w 到每个其他顶点的最短路径。设 x 是最大最短路径距离的顶点。从 w 到 x 的路径给出直径。

  27. 使用并查集查找桥梁。 设 T 是一个连通图 G 的生成树。图 G 中的每条非树边 e 形成一个由边 e 和树中连接其端点的唯一路径组成的基本环。证明一条边是桥梁当且仅当它不在某个基本环上。因此,所有桥梁都是生成树的边。设计一个算法,使用 E + V 时间加上 E + V 并查集操作,找到所有桥梁(和桥梁组件)。

  28. 非递归深度优先搜索。 编写一个程序 NonrecursiveDFS.java,使用显式堆栈而不是递归来实现深度优先搜索。

    这是 Bin Jiang 在 1990 年代初提出的另一种实现。唯一额外的内存是用于顶点堆栈,但该堆栈必须支持任意删除(或至少将任意项移动到堆栈顶部)。

    private void dfs(Graph G, int s) {
        SuperStack<Integer> stack = new SuperStack<Integer>();
        stack.push(s);
        while (!stack.isEmpty()) {
            int v = stack.peek();
            if (!marked[v]) {
                marked[v] = true;
                for (int w : G.adj(v)) {
                    if (!marked[w]) {
                        if (stack.contains(w)) stack.delete(w);
                        stack.push(w);
                    }
                }
            }
            else {
                // v's adjacency list is exhausted
                stack.pop();
            }
        }
    }
    
    

    这里是另一种实现。这可能是最简单的非递归实现,但在最坏情况下使用的空间与 E + V 成比例(因为一个顶点的多个副本可能在堆栈上),并且以标准递归 DFS 的相反顺序探索与 v 相邻的顶点。此外,edgeTo[v] 条目可能被更新多次,因此可能不适用于回溯应用。

    private void dfs(Graph G, int s) {
        Stack<Integer> stack = new Stack<Integer>();
        stack.push(s);
        while (!stack.isEmpty()) {
            int v = stack.pop();
            if (!marked[v]) {
                marked[v] = true;
                for (int w : G.adj(v)) {
                    if (!marked[w]) {
                        edgeTo[w] = v;
                        stack.push(w);
                     }
                }
            }
        }
    }
    
    
  29. 非递归深度优先搜索。 解释为什么以下非递归方法(类似于 BFS,但使用堆栈而不是队列)实现深度优先搜索。

    private void dfs(Graph G, int s) {
        Stack<Integer> stack = new Stack<Integer>();
        stack.push(s);
        marked[s] = true;
        while (!stack.isEmpty()) {
           int v = stack.pop();
           for (int w : G.adj(v)) {
               if (!marked[w]) {
                   stack.push(w);
                   marked[w] = true;
                   edgeTo[w] = v;
               }
           }
        }
    }
    
    

    *解决方案:*考虑由边 0-1、0-2、1-2 和 2-1 组成的图,其中顶点 0 为源。

  30. Matlab 连通分量。 在 Matlab 中,bwlabel() 或 bwlabeln() 用于标记 2D 或 kD 二进制图像中的连通分量。bwconncomp() 是更新版本。

  31. 互补图中的最短路径。 给定一个图 G,设计一个算法来找到从 s 到互补图 G' 中每个其他顶点的最短路径(边的数量)。互补图包含与 G 相同的顶点,但只有当边 v-w 不在 G 中时才包含边 v-w。你能否比明确计算互补图 G' 并在 G' 中运行 BFS 做得更好?

  32. 删除一个顶点而不断开图。 给定一个连通图,设计一个线性时间算法来找到一个顶点,其移除(删除顶点和所有关联边)不会断开图。

    提示 1(使用 DFS):从某个顶点 s 运行 DFS,并考虑 DFS 中完成的第一个顶点。

    提示 2(使用 BFS):从某个顶点 s 运行 BFS,并考虑具有最大距离的任何顶点。

  33. 生成树。 设计一个算法,以时间复杂度为 V + E 计算一个连通图的生成树。提示:使用 BFS 或 DFS。

  34. 图中的所有路径。 编写一个程序 AllPaths.java,枚举图中两个指定顶点之间的所有简单路径。提示:使用 DFS 和回溯。警告:图中可能存在指数多个简单路径,因此对于大型图,没有算法可以高效运行。