数据结构面试题:树与图


1. 实现一个函数,检查二叉树是否平衡

在这个问题中,平衡树的定义如下:任意一个结点,其两棵子树的高度差不超过1。

还算幸运,此题至少明确给出了平衡树的定义:任意一个结点,其两棵子树的高度差不大于1。根据该定义可以得到一种解法,即直接递归访问整棵树,计算每个结点两棵子树的高度。

public static int getHeight(TreeNode root) {
    if (root == null) 
        return 0; // 终止条件
    return Math.max(getHeight(root.left),
        getHeight(root.right)) + 1;
}

public static boolean isBalanced(TreeNode root) {
    if (root == null) 
        return true; // 终止条件

    int heightDiff = getHeight(root.left) - getHeight(root.right);
    if (Math.abs(heightDiff) > 1) {
        return false;
    } else { // 递归
        return isBalanced(root.left) && isBalanced(root.right);
    }
}

虽然可行,但效率不太高,这段代码会递归访问每个结点的整棵子树。也就是说,getHeight会被反复调用计算同一个结点的高度。因此,这个算法的时间复杂度为O(N log N)

我们可以删减部分getHeight调用。

仔细查看上面的方法,你或许会发现,getHeight不仅可以检查高度,还能检查这棵树是否平衡。那么,我们发现子树不平衡时又该怎么做呢?直接返回-1。

改进过的算法会从根结点递归向下检查每棵子树的高度。我们会通过checkHeight方法,以递归方式获取每个结点左右子树的高度。若子树是平衡的,则checkHeight返回该子树的实际高度。若子树不平衡,则checkHeight返回-1。checkHeight会立即中断执行,并返回-1。

下面是该算法的实现代码。

public static int checkHeight(TreeNode root) {
    if (root == null) {
        return 0; // 高度为0
    }

    /* 检查左子树是否平衡*/
    int leftHeight = checkHeight(root.left);
    if (leftHeight == -1) {
        return -1; // 不平衡
    }
    /* 检查左子树是否平衡*/
    int rightHeight = checkHeight(root.right);
    if (rightHeight == -1) {
        return -1; // 不平衡
    }

    /* 检查当前结点是否平衡*/
    int heightDiff = leftHeight - rightHeight;
    if (Math.abs(heightDiff) > 1) {
        return -1; // 不平衡
    } else {
        /* 返回高度*/
        return Math.max(leftHeight, rightHeight) + 1;
    }
}

public static boolean isBalanced(TreeNode root) {
    if (checkHeight(root) == -1) {
        return false;
    } else {
        return true;
    }
}

这段代码需要O(N)的时间和O(H)的空间,其中H为树的高度。

2. 创建一棵高度最小的二叉查找树

给定一个有序整数数组,元素各不相同且按升序排列。

要创建一棵高度最小的树,就必须让左右子树的结点数量越接近越好。也就是说,我们要让数组中间的值成为根结点,这么一来,数组左边一半就成为左子树,右边一半成为右子树。

然后,我们继续以类似方式构造整棵树。数组每一区段的中间元素成为子树的根结点,左半部分成为左子树,右半部分成为右子树。

一种实现方式是使用简单的root.insertNode(int v)方法,从根结点开始,以递归方式将值v插入树中。这么做的确能构造最小高度的树,但效率并不是太高。每次插入操作都要遍历整棵树,时间开销为O(N log N)

另一种做法是以递归方式运用createMinimalBST方法,从而消除部分多余的遍历操作。这个方法会传入数组的一个区段,并返回最小树的根结点。

该算法简述如下。

  1. 将数组中间位置的元素插入树中。
  2. 将数组左半边元素插入左子树。
  3. 将数组右半边元素插入右子树。
  4. 递归处理。

下面是该算法的实现代码。

TreeNode createMinimalBST(int arr[], int start, int end) {
    if (end < start) {
        return null;
    }
    int mid = (start + end) / 2;
    TreeNode n = new TreeNode(arr[mid]);
    n.left = createMinimalBST(arr, start, mid - 1);
    n.right = createMinimalBST(arr, mid + 1, end);
    return n;
}

TreeNode createMinimalBST(int array[]) {
    return createMinimalBST(array, 0, array.length - 1);
}

尽管这段代码看起来不是特别复杂,但在编写过程中很容易犯了差一错误(off-by-one)。对这部分代码,务必进行详尽测试。

3. 创建含有一颗二叉树某一深度上所有结点的链表

给定一棵二叉树,设计一个算法,创建含有某一深度上所有结点的链表,比如,若一棵树的深度为D,则会创建出D个链表。

乍一看,你可能认为这个问题需要一层一层逐一遍历,但其实并无必要。你可以用任意方式遍历整棵树,只要记住结点位于哪一层即可。

我们可以将前序遍历算法稍作修改,将level + 1传入下一个递归调用。下面是使用深度优先搜索的实现代码。

void createLevelLinkedList(TreeNode root,
    ArrayList<LinkedList<TreeNode>> lists, int level) {
    if (root == null) 
        return; // 终止条件

    LinkedList<TreeNode> list = null;
    if (lists.size() == level) { // 该层不在链表中
        list = new LinkedList<TreeNode>();
        /* 以中序遍历所有层级,因此,若这是第一次
         * 访问第i层,则表示我们已访问过第0到i-1层。
         * 因此,我们可以安全地将这一层加到链表
         * 末端。*/
        lists.add(list);
    } else {
        list = lists.get(level);
    }
    list.add(root);
    createLevelLinkedList(root.left, lists, level + 1);
    createLevelLinkedList(root.right, lists, level + 1);
}

ArrayList<LinkedList<TreeNode>> createLevelLinkedList(
        TreeNode root) {
    ArrayList<LinkedList<TreeNode>> lists =
        new ArrayList<LinkedList<TreeNode>>();
    createLevelLinkedList(root, lists, 0);
    return lists;
}

另一种做法是对广度优先搜索稍加修改,即从根结点开始迭代,然后第2层,第3层,等等。

处于第i层时,则表明我们已访问过第i-1层的所有结点。也就是说,要得到i层的结点,只需直接查看i-1层结点的所有子结点即可。

下面是该算法的实现代码。

ArrayList<LinkedList<TreeNode>> createLevelLinkedList(TreeNode root) {
    ArrayList<LinkedList<TreeNode>> result =
        new ArrayList<LinkedList<TreeNode>>();
    /* 访问根结点*/
    LinkedList<TreeNode> current = new LinkedList<TreeNode>();
    if (root != null) {
        current.add(root);
    }

    while (current.size() > 0) {
        result.add(current); // 加入上一层
        LinkedList<TreeNode> parents = current; // 转到下一层
        current = new LinkedList<TreeNode>();
        for (TreeNode parent : parents) {
            /* 访问子结点*/
            if (parent.left != null) {
                current.add(parent.left);
            }
            if (parent.right != null) {
                current.add(parent.right);
            }
        }
    }
    return result;
}

你可能会问,这两种解法哪一种效率更高?两者的时间复杂度皆为O(N),那么空间效率呢?乍一看,我们可能会以为第二种解法的空间效率更高。

在某种意义上,这么说也对。第一种解法会用到O(log N)次递归调用(在平衡树中),每次调用都会在栈里增加一级。第二种解法采用迭代遍历法,不需要这部分额外空间。

不过,两种解法都要返回O(N)数据,因此,递归实现所需的额外O(log N)空间,跟必须传回的O(N)数据相比,并不算多。虽然第一种解法确实使用了较多的空间,但从大O记法的角度来看,两者效率是一样的。

4. 实现一个函数,检查一棵二叉树是否为二叉查找树

此题有两种不同的解法。第一种是利用中序遍历,第二种则建立在left <= current < right这项特性之上。

解法1:中序遍历

看到此题,闪过的第一个想法可能是中序遍历,将所有元素复制到数组中,然后检查该数组是否有序。这种解法要多用一点内存,大部分情况下都没问题。

唯一的问题在于,它无法正确处理树中的重复值。例如,该算法无法区分下面这两棵树(其中一棵是无效的),因为两者的中序遍历结果相同。

Valid BST [20.left = 20]
Invalid BST [20.right = 20]

不过,要是假定这棵树不得包含重复值,那么这种做法还是行之有效的。该方法的伪码大致如下:

public static int index = 0;
public static void copyBST(TreeNode root, int[] array) {
    if (root == null) 
        return;
    copyBST(root.left, array);
    array[index] = root.data;
    index++;
    copyBST(root.right, array);
}

public static boolean checkBST(TreeNode root) {
    int[] array = new int[root.size];
    copyBST(root, array);
    for (int i = 1; i < array.length; i++) {
        if (array[i] <= array[i - 1]) 
            return false;
    }
    return true;
}

注意,这里必须记录数组在逻辑上的“尾部”,用它来分配空间以储存所有元素。

仔细审视这个解法,我们就会发现代码中的数组实无必要。除了用来比较某个元素和前一个元素,别无他用。那么,为什么不在进行比较时,直接记下最后的元素?

下面是该算法的实现代码。

public static int last_printed = Integer.MIN_VALUE;
public static boolean checkBST(TreeNode n) {
    if (n == null) 
        return true;

    // 递归检查左子树
    if (!checkBST(n.left)) 
        return false;

    // 检查当前结点
    if (n.data <= last_printed) 
        return false;
    last_printed = n.data;

    // 递归检查右子树
    if (!checkBST(n.right)) 
        return false;

    return true; // 全部检查完毕
}

要是不喜欢使用静态变量,可以稍作修改,使用包裹类存放这个整数值,如下所示:

class WrapInt {
    public int value;
}

或者,若用C++或其他支持按引用传值的语言实现,就可以这么做。

解法2:最小/最大法

第二种解法利用的是二叉查找树的定义。

一棵什么样的树才成其为二叉查找树?我们知道这棵树必须满足以下条件:对于每个结点,left.data <= current.data < right.data,但是这样还不够。试看下面这棵小树:

尽管每个结点都比左子结点大,比右子结点小,但这显然不是一棵二叉查找树,其中25的位置不对。

更准确地说,成为二叉查找树的条件是:所有左边的结点必须小于或等于当前结点,而当前结点必须小于所有右边的结点。

利用这一点,我们可以通过自上而下传递最小和最大值来解决这个问题。在迭代遍历整个树的过程中,我们会用逐渐变窄的范围来检查各个结点。

以下面这棵树为例:

首先,从(min = INT_MIN, max = INT_MAX)这个范围开始,根结点显然落在其中。然后处理左子树,检查这些结点是否落在(min = INT_MIN, max = 20)范围内。然后再处理(值为10的结点)右子树,检查结点是否落在(min = 20, max = INT_MAX)范围内。

然后,继续依此遍历整棵树。进入左子树时,更新max。进入右子树时,更新min。只要有任一结点不能通过检查,则停止并返回false。

这种解法的时间复杂度为O(N),其中N为整棵树的结点数。我们可以证明这已经是最佳做法,因为任何算法都必须访问全部N个结点。

因为用了递归,对于平衡树,空间复杂度为O(log N)。在调用栈上,共有O(log N)个递归用,因为递归的深度最大会到这棵树的深度。

该解法的递归实现代码如下:

boolean checkBST(TreeNode n) {
    return checkBST(n, Integer.MIN_VALUE, Integer.MAX_VALUE);
}

boolean checkBST(TreeNode n, int min, int max) {
    if (n == null) {
        return true;
    }
    if (n.data < min || n.data >= max) {
        return false;
    }

    if (!checkBST(n.left, min, n.data) 
            || !checkBST(n.right, n.data, max)) {
        return false;
    }
    return true;
}

记住,在递归算法中,一定要确定终止条件以及结点为空的情况得到妥善处理。

5. 找出二叉查找树中指定结点的“下一个”结点(也即中序后继)

可以假定每个结点都含有指向父结点的连接

回想一下中序遍历,它会遍历左子树,然后是当前结点,接着是右子树。要解决这个问题,必须非常小心,想想具体是怎么回事。

假定我们有一个假想的结点。我们知道访问顺序为左子树,当前结点,然后是右子树。显然,下一个结点应该位于右边。

不过,到底是右子树的哪个结点呢?如果中序遍历右子树,那它就会是接下来第一个被访问的结点,也就是说,它应该是右子树最左边的结点。够简单吧!

但是,若这个结点没有右子树,又该怎么办?这种情况就有点棘手了。

若结点n没有右子树,那就表示已遍访n的子树。我们必须回到n的父结点,记作q。

若n在q的左边,那么,下一个我们应该访问的结点就是q(中序遍历,left -> current ->right)。

若n在q的右边,则表示已遍访q的子树。我们需要从q往上访问,直至找到我们还未完全遍访过的结点x。怎么才能知道还未完全遍历结点x呢?之前从左结点访问至其父结点时,就已碰到了这种情况。左结点已完全遍历,但其父结点尚未完全遍历。

伪码如下:

Node inorderSucc(Node n) {
    if (n has a right subtree) {
        return leftmost child of right subtree
    } else {
        while (n is a right child of n.parent) {
            n = n.parent; // 往上
        }
        return n.parent; // 父结点还未遍历
    }
}

且慢,如果一路往上遍访这棵树都没发现左结点呢?只有当我们遇到中序遍历的最末端时,才会出现这种情况。也就是说,如果我们已位于树的最右边,那就不会再有中序后继,此时该返回null。

下面是该算法的实现代码(已正确处理结点为空的情况)。

public TreeNode inorderSucc(TreeNode n) {
    if (n == null) 
        return null;

    /* 找到右子结点,则返回右子树里
     * 最左边的结点*/
    if (n.right != null)
        return leftMostChild(n.right);
    } else {
        TreeNode q = n;
        TreeNode x = q.parent;
        // 向上直至位于左边而不是右边
        while (x != null && x.left != q) {
            q = x;
            x = x.parent;
        }
        return x;
    }
}

public TreeNode leftMostChild(TreeNode n) {
    if (n == null) {
        return null;
    }
    while (n.left != null) {
        n = n.left;
    }
    return n;
}

这不是世上最复杂的算法问题,但要写出完美无瑕的代码却有难度。面对这类问题,比较实用的做法是用伪码勾勒大纲,仔细描绘各种不同的情况。

6. 找出二叉树中某两个结点的第一个共同祖先

设计并实现一个算法,找出二叉树中某两个结点的第一个共同祖先。不得将额外的结点储存在另外的数据结构中。注意:这不一定是二叉查找树。

如果是二叉查找树,我们可以修改find操作,用来查找这两个结点,看看路径在哪里开始分叉。可惜,这不是二叉查找树,因此必须另觅他法。

下面假定我们要找出结点p和q的共同祖先。在此先要问个问题,这棵树的结点是否包含指向父结点的连接。

解法1:包含指向父结点的连接

如果每个结点都包含指向父结点的连接,我们就可以向上追踪p和q的路径,直至两者相交。不过,这么做可能不符合题目的若干假设,因为它需要满足以下两个条件之一:(1) 可将结点标记为isVisited;(2) 可用另外的数据结构如散列表储存一些数据。

解法2:不包含指向父结点的连接

另一种做法是,顺着一条p和q都在同一边的链子,也就是说,若p和q都在某结点的左边,就到左子树中查找共同祖先。若都在右边,则在右子树中查找共同祖先。要是p和q不在同一边,那就表示已经找到第一个共同祖先。

这种做法的实现代码如下。

/* 若p为root的子孙,则返回true */
boolean covers(TreeNode root, TreeNode p) {
    if (root == null) 
        return false;
    if (root == p) 
        return true;
    return covers(root.left, p) || covers(root.right, p);
}

TreeNode commonAncestorHelper(TreeNode root, TreeNode p, TreeNode q) {
    if (root == null) 
        return null;
    if (root == p || root == q) 
        return root;

    boolean is_p_on_left = covers(root.left, p);
    boolean is_q_on_left = covers(root.left, q);

    /* 若p和q不在同一边,则返回root */
    if (is_p_on_left != is_q_on_left) 
        return root;

    /* 否则就是在同一边,遍访那一边*/
    TreeNode child_side = is_p_on_left ? root.left : root.right;
    return commonAncestorHelper(child_side, p, q);
}

TreeNode commonAncestor(TreeNode root, TreeNode p, TreeNode q) {
    if (!covers(root, p) || !covers(root, q)) { // 错误检查
        return null;
    }
    return commonAncestorHelper(root, p, q);
}

这个算法在平衡树上的运行时间为O(n)。这是因为第一次调用时,covers会在2n个结点上调用(左边n个结点,右边n个结点)。接着,该算法会访问左子树或右子树,此时covers会在2n/2个结点上调用,然后是2n/4,依此类推。最终的运行时间为O(n)

至此,就渐近式运行时间(asymptotic runtime)来看,可以确定没有更优解了,因为必须遍访这棵树的每一个结点才行。不过,或许我们还能减小常数倍的值。

解法3:最优化

尽管解法2在运行时间上已经做到最优,还是可以看出部分低效的操作。特别是,covers会搜索root下的所有结点以查找p和q,包括每棵子树中的结点(root.left和root.right)。然后,它会选择那些子树中的一棵,搜遍它的所有结点。每棵子树都会被一再地反复搜索。

你可能会觉察到,只需搜索一遍整棵树,就能找到p和q。然后,就可以“往上冒泡”在栈里找到先前的结点。基本逻辑与上一种解法相同。

使用函数commonAncestor(TreeNode root, TreeNode p, TreeNode q)递归访问整棵树,这个函数的返回值如下:

  • 返回p,若root的子树含有p(而非q);
  • 返回q,若root的子树含有q(而非p);
  • 返回null,若p和q都不在root的子树中;
  • 否则,返回p和q的共同祖先。

在最后一种情况下,要找到p和q的共同祖先比较简单。当commonAncestor(n.left, p, q)commonAncestor(n.right, p, q)都返回非空的值时(意即p和q位于不同的子树中),则n即为共同祖先。

下面的代码提供了初步的解法,不过其中有个bug。试着找找看。

/* 下面的代码有个bug */
TreeNode commonAncestorBad(TreeNode root, TreeNode p, TreeNode q) {
    if (root == null) {
        return null;
    }
    if (root == p && root == q) {
        return root;
    }

    TreeNode x = commonAncestorBad(root.left, p, q);
    if (x != null && x != p && x != q) { // 已经找到父系结点
        return x;
    }

    TreeNode y = commonAncestorBad(root.right, p, q);
    if (y != null && y != p && y != q) { // 已经找到父系结点
        return y;
    }

    if (x != null && y != null) { // 在不同子树里找到p和q
        return root; // 这是共同祖先
    } else if (root == p || root == q) {
        return root;
    } else {
        /* x或y有一个非空,则返回非空的那个值*/
        return x == null ? y : x;
    }
}

假如有个结点不在这棵树中,这段代码就会出问题。例如,请看下面这棵树:

假设我们调用commonAncestor(node 3, node 5, node 7)。当然,结点7并不存在,而这正是问题的源头。调用序列如下:

commonAncestor(node 3, node 5, node 7)           // --> 5
  calls commonAncestor(node 1, node 5, node 7)   // --> null
  calls commonAncestor(node 5, node 5, node 7)   // --> 5
    calls commonAncestor(node 8, node 5, node 7) // --> null

换句话说,对右子树调用commonAncestor时,前面的代码会返回结点5,这也符合代码本意。问题在于查找p和q的共同祖先时,调用函数无法区分下面两种情况。

  • 情况1:p是q的子结点(或相反,q为p的子结点)。
  • 情况2:p在这棵树中,而q不在这棵树中(或者相反)。

不论哪种情况,commonAncestor都将返回p。对于情况1,这是正确的返回值,而对于情况2,返回值应该为null。

我们需要设法区分这两种情况,这也是以下代码所做的。这段代码的做法是返回两个值:结点自身,以及指示这个结点是否确为共同祖先的标记。

public static class Result {
    public TreeNode node;
    public boolean isAncestor;
    public Result(TreeNode n, boolean isAnc) {
        node = n;
        isAncestor = isAnc;
    }
}

Result commonAncestorHelper(TreeNode root, TreeNode p, TreeNode q){
    if (root == null) {
        return new Result(null, false);
    }
    if (root == p && root == q) {
        return new Result(root, true);
    }

    Result rx = commonAncestorHelper(root.left, p, q);
    if (rx.isAncestor) { // 找到共同祖先
        return rx;
    }

    Result ry = commonAncestorHelper(root.right, p, q);
    if (ry.isAncestor) { // 找到共同祖先
        return ry;
    }

    if (rx.node != null && ry.node != null) {
        return new Result(root, true); // 这是共同祖先
    } else if (root == p || root == q) {
        /* 若我们当前位于p或q,并发现其中一个结点
         * 位于子树中,那么这真的就是一个共同祖先,
         * 标记应该设为true。*/
        boolean isAncestor = rx.node != null || ry.node != null ? true : false;
        return new Result(root, isAncestor);
    } else {
        return new Result(rx.node!=null ? rx.node : ry.node, false);
    }
}

TreeNode commonAncestor(TreeNode root, TreeNode p, TreeNode q) {
    Result r = commonAncestorHelper(root, p, q);
    if (r.isAncestor) {
        return r.node;
    }
    return null;
}

当然,由于这个问题只会在p或q并不属于这棵树的情况下出现,另一种避免bug的做法是先搜遍整棵树,以确保两个结点都在树中。

7. 判断T2是否为T1的子树

你有两棵非常大的二叉树:T1,有几百万个结点;T2,有几百个结点。设计一个算法,判断T2 是否为T1 的子树。

如果T1 有这么一个结点n,其子树与T2 一模一样,则T2 为T1 的子树。也就是说,从结点n处把树砍断,得到的树与T2 完全相同。

碰到类似的问题,不妨假设只有少量的数据,以此为基础解决问题。这么做很有用,可以借此找出可行的基本解法。

在规模较小且较简单的问题中,我们可以创建一个字符串,表示中序和前序遍历。若T2前序遍历是T1前序遍历的子串,并且T2中序遍历是T1中序遍历的子串,则T2为T1的子树。利用后缀树可以在线性时间内检查是否为子串,因此就最差情况的时间复杂度而言,这个算法是相当高效的。

注意,我们需要在字符串中插入特殊字符,表示左结点或右结点为NULL的情况。否则,我们就无法区分以下两种情况:

尽管这两棵树不同,但两者的中序和前序遍历完全一样。

T1,中序:3, 3
T1,前序:3, 3
T2,中序:3, 3
T2,前序:3, 3

不过,要是标记出NULL值,就能区分这两棵树:

T1,中序:0, 3, 0, 3, 0
T1,前序:3, 3, 0, 0, 0
T2,中序:0, 3, 0, 3, 0
T2,前序:3, 0, 3, 0, 0

对于简单的情形,这种解法还算不错,但是我们真正要面对的问题涉及的数据量要大得多。鉴于该问题指定的约束条件,创建两棵树的副本可能要占用太多的内存。

另一种解法

另一种解法是搜遍较大的那棵树T1。每当T1的某个结点与T2的根结点匹配时,就调用treeMatch。treeMatch方法会比较两棵子树,检查两者是否相同。

分析运行时间有点复杂,粗略一看的答案可能是O(nm),其中n为T1的结点数,m为T2的结点数。虽然在技术上这个答案是正确的,但稍微再想想就能得到更靠谱的答案。

我们不必对T2的每个结点调用treeMatch,而是会调用k次,其中k为T2根结点在T1中出现的次数。因此运行时间接近O(n + km)。

其实,即使这样运行时间也有所夸大。即使根结点相同,一旦发现T1和T2有结点不同,我们就会退出treeMatch。因此,每次调用treeMatch,也不见得都会查看m个结点。

下面是该算法的实现代码。

boolean containsTree(TreeNode t1, TreeNode t2) {
    if (t2 == null) { // 空树一定是子树
        return true;
    }
    return subTree(t1, t2);
}

boolean subTree(TreeNode r1, TreeNode r2) {
    if (r1 == null) {
        return false; // 大的树已经空了,还未找到子树
    }
    if (r1.data == r2.data) {
        if (matchTree(r1,r2)) 
            return true;
    }
    return (subTree(r1.left, r2) || subTree(r1.right, r2));
}

boolean matchTree(TreeNode r1, TreeNode r2) {
    if (r2 == null && r1 == null) // 若两者都空
        return true; // 子树中已无结点

    // 若其中之一为空,但并不同时为空
    if (r1 == null || r2 == null) {
        return false;
    }

    if (r1.data != r2.data)
        return false; // 结点数据不匹配
    return (matchTree(r1.left, r2.left) && matchTree(r1.right, r2.right));
    }
}

什么情况下用简单解法比较好,什么时候另一种解法比较好呢?这个问题值得跟面试官好好讨论一番,下面是几点注意事项。

  1. 简单解法会占用O(n + m)内存,而另一种解法则占用O(log(n) + log(m))内存。记住:要求可扩展性时,内存使用多寡关系重大。
  2. 简单解法的时间复杂度为O(n + m),另一种解法在最差情况下的执行时间为O(nm)。话说回来,只看最差情况的时间复杂度可能会造成误导,我们需要做进一步观察。
  3. 如前所述,比较准的运行时间为O(n + km),其中k为T2根结点在T1中出现的次数。假设T1和T2的结点数据为0和p之间的随机数,则k值大约为n / p,为什么?因为T1有n个结点,每个结点有1/p的几率与T2根结点相同,因此,T1中大约有n / p个结点等于T2根结点(T2.root)。举个例子,假设p = 1000,n = 1 000 000且m = 100。我们需要检查的结点数量大致为1 100 000(1100 000 = 1 000 000 + 100*1 000 000/1000)。
  4. 借助更复杂的数学运算和假设,就能得到更准确的运行时间。在第3点中,我们假设调用treeMatch时将遍历T2的全部m个结点。然而,更有可能出现的情况是,我们很早就发现两棵树有不同的结点,然后提早就退出了这个函数。

总的来说,在空间使用上,另一种解法显然比较好,在时间复杂度上,也可能比简单解法更优。一切都取决于你做出哪些假设,以及要不要考虑牺牲最差情况的运行时间,来减少平均情况的运行时间。这一点非常值得向面试官提出并讨论。

8. 打印二叉树节点数值和等于给定数值的所有路径

给定一棵二叉树,其中每个结点都含有一个数值。设计一个算法,打印结点数值总和等于某个给定值的所有路径。注意,路径不一定非得从二叉树的根结点或叶结点开始或结束。

下面我们运用简化推广法来解题。

部分1:简化——假设路径必须从根结点开始,但可以在任意结点结束,怎么解决?

在这种情况下,问题就会变得容易很多。

我们可以从根结点开始,向左向右访问子结点,计算每条路径上到当前结点为止的数值总和,若与给定值相同则打印当前路径。注意,就算找到总和,仍要继续访问这条路径。为什么?因为这条路径可能继续往下经过a + 1结点和a - 1结点(或其他数值总和为0的结点序列),完整路径的总和仍然等于sum。

例如,若sum = 5,可能会得到以下路径:

p = {2, 3}
q = {2, 3, -4, -2, 6}

如果找到2 + 3就停下来,我们就会错过第二条路径,还可能错过其他路径。因此,我们必须继续往下查找所有可能的路径。

部分2:推广——路径可从任意结点开始。

现在,如果路径可从任意结点开始,该怎么办?在这种情况下,我们可以稍作调整。对于每个结点,我们都会向“上”检查是否得到相符的总和。也就是说,我们不再要求“从这个结点开始是否会有总和为给定值的路径”,而是关注“这个结点是否为总和为给定值的某条路径的末端”。

递归访问每个结点n时,我们会将root到n的完整路径传入该函数。随后,这个函数会以相反的顺序,从n到root,将路径上的结点值加起来。当每条子路径的总和等于sum时,就打印这条路径。

public void findSum(TreeNode node, int sum, int[] path, int level) {
    if (node == null) {
        return;
    }

    /* 将当前结点插入路径*/
    path[level] = node.data;

    /* 查找以此为终点且总和为sum的路径*/
    int t = 0;
    for (int i = level; i >= 0; i--){
        t += path[i];
        if (t == sum) {
            print(path, i, level);
        }
    }

    /* 查找此结点之下的结点*/
    findSum(node.left, sum, path, level + 1);
    findSum(node.right, sum, path, level + 1);

    /* 从路径中移除当前结点。严格来说并不一定要这么做,
     * 直接忽略这个值即可,但这么做是个好习惯*/
    path[level] = Integer.MIN_VALUE;
}

public void findSum(TreeNode node, int sum) {
    int depth = depth(node);
    int[] path = new int[depth];
    findSum(node, sum, path, 0);
}

public static void print(int[] path, int start, int end) {
    for (int i = start; i <= end; i++) {
        System.out.print(path[i] + “ “);
    }
    System.out.println();
}

public int depth(TreeNode node) {
    if (node == null) {
        return 0;
    } else {
        return 1 + Math.max(depth(node.left), depth(node.right));
    }
}

那么,这个算法的时间复杂度如何(假设是棵平衡二叉树)?如果结点在r层,那么就需要r份量的工作(向“上”检查结点的步骤)。我们可以猜测时间复杂度为O(n log(n)),因为总共有n个结点,平均下来,每一步需要log(n)的工作量。

如果这么分析,你还是看不大明白,我们也可以用严格的数学推导来说明。注意,在r层上有 个结点。

注意,,因此,

按照同样的逻辑,可以推导出算法的空间复杂度为O(log(n)),因为该算法会递归O(log n)次,而在递归调用中参数path只分配一次空间(大小为O(log n))。

results matching ""

    No results matching ""