算法和数据结构
1.设计包含min函数的栈[数据结构]
题目:定义栈的数据结构,要求添加一个min函数,能够得到栈的最小元素。要求函数min、push以及pop的时间复杂度都是O(1)。
2.子数组的最大和[算法]
题目:输入一个整形数组,数组里有正数也有负数。数组中连续的一个或多个整数组成一个子数组,每个子数组都有一个和。求所有子数组的和的最大值。要求时间复杂度为O(n)。
例如输入的数组为1, -2, 3, 10, -4, 7, 2, -5,和最大的子数组为3, 10, -4, 7, 2,因此输出为该子数组的和18。
3.元树中和为某一值的所有路径[数据结构]
题目:输入一个整数和一棵二元树。从树的根结点开始往下访问一直到叶结点所经过的所有结点形成一条路径。打印出和与输入整数相等的所有路径。
例如输入整数22和如下二元树
10
5 12
4 7
则打印出两条路径:10, 12和10, 5, 7。
class Program { static void Main(string[] args) { Node root = new Node() { Value = 10 }; root.Left = new Node() { Value = 5 }; root.Right = new Node() { Value = 12 }; root.Left.Left = new Node() { Value = 4 }; root.Left.Right = new Node() { Value = 7 }; PrintPath(root, 22, new Stack<int>(), 0); } static void PrintPath(Node node, int expectedSum, Stack<int> pathNums, int currentSum) { if (node == null) { return; } pathNums.Push(node.Value); currentSum = currentSum + node.Value; if (node.Left == null && node.Right == null) { if (currentSum == expectedSum) { foreach (var item in pathNums) { Console.WriteLine(item); } } } PrintPath(node.Left, expectedSum, pathNums, currentSum); PrintPath(node.Right, expectedSum, pathNums, currentSum); pathNums.Pop(); currentSum = currentSum - node.Value; } } }
4.二元查找树的后序遍历结果[数据结构]
题目:输入一个整数数组,判断该数组是不是某二元查找树的后序遍历的结果。如果是返回true,否则返回false。
例如输入5、7、6、9、11、10、8,由于这一整数序列是如下树的后序遍历结果:
8
6 10
5 7 9 11
因此返回true。
如果输入7、4、6、5,没有哪棵树的后序遍历的结果是这个序列,因此返回false。
static void Main(string[] args) { List<int> arr = new List<int> { 1, 6, 5, 8, 7, 10, 12, 11, 9 }; bool isVerified = VerifySquenceOfBST(arr); } static bool VerifySquenceOfBST(List<int> arr) { if (arr.Count == 0) { return false; } int mid = arr[arr.Count - 1]; List<int> left = new List<int>(); List<int> right = new List<int>(); bool isBigger = false; for (int i = 0; i < arr.Count - 1; i++) { if (arr[i] > mid) { isBigger = true; } if (isBigger) { right.Add(arr[i]); } else { left.Add(arr[i]); } } foreach (var item in right) { if (item < mid) { return false; } } bool isLeft = true; if (left.Count > 0) { isLeft = VerifySquenceOfBST(left); } bool isRight = true; if (right.Count > 0) { isRight = VerifySquenceOfBST(right); } return isLeft && isRight; }
5.排序数组中和为给定值的两个数字
题目:输入一个已经按升序排序过的数组和一个数字,在数组中查找两个数,使得它们的和正好是输入的那个数字。要求时间复杂度是O(n)。如果有多对数字的和等于输入的数字,输出任意一对即可。
例如输入数组1、2、4、7、11、15和数字15。由于4+11=15,因此输出4和11。
分析:如果我们不考虑时间复杂度,最简单想法的莫过去先在数组中固定一个数字,再依次判断数组中剩下的n-1个数字与它的和是不是等于输入的数字。可惜这种思路需要的时间复杂度是O(n2)。
我们假设现在随便在数组中找到两个数。如果它们的和等于输入的数字,那太好了,我们找到了要找的两个数字;如果小于输入的数字呢?我们希望两个数字的和再大一点。由于数组已经排好序了,我们是不是可以把较小的数字的往后面移动一个数字?因为排在后面的数字要大一些,那么两个数字的和也要大一些,就有可能等于输入的数字了;同样,当两个数字的和大于输入的数字的时候,我们把较大的数字往前移动,因为排在数组前面的数字要小一些,它们的和就有可能等于输入的数字了。
我们把前面的思路整理一下:最初我们找到数组的第一个数字和最后一个数字。当两个数字的和大于输入的数字时,把较大的数字往前移动;当两个数字的和小于数字时,把较小的数字往后移动;当相等时,打完收工。这样扫描的顺序是从数组的两端向数组的中间扫描。
问题是这样的思路是不是正确的呢?这需要严格的数学证明。感兴趣的读者可以自行证明一下。
static void Main(string[] args) { List<int> arr = new List<int> { 7, 4, 6, 5 }; var result = FindTwoNumbersWithSum(new int[] { 1, 2, 4, 7, 11, 15 }, 15); } static List<int> FindTwoNumbersWithSum(int[] data, int sum) { List<int> result = null; int length = data.Length; int behind = length - 1; int ahead = 0; while (ahead < behind) { int currentSum = data[ahead] + data[behind]; if (currentSum == sum) { result = new List<int>(); result.Add(data[ahead]); result.Add(data[behind]); return result; } else if (currentSum > sum) { behind = behind - 1; } else if (currentSum < sum) { ahead = ahead + 1; } } return result; }
6.求二元查找树的镜像(分别用递归和循环)
static void Main(string[] args) { Node root = new Node() { Value = 8 }; root.Left = new Node() { Value = 6 }; root.Right = new Node() { Value = 10 }; root.Left.Left = new Node() { Value = 5 }; root.Left.Right = new Node() { Value = 7 }; root.Right.Left = new Node() { Value = 9 }; root.Right.Right = new Node() { Value = 11 }; MirrorReverse(root); } static void MirrorReverse(Node node) { if (node == null) { return; } Node temp = node.Left; node.Left = node.Right; node.Right = temp; MirrorReverse(node.Left); MirrorReverse(node.Right); }
static void Reverse(Node node) { Stack<Node> stack = new Stack<Node>(); stack.Push(node); while (stack.Count > 0) { Node current = stack.Pop(); Node temp = current.Left; current.Left = current.Right; current.Right = temp; if (current.Left != null) { stack.Push(current.Left); } if (current.Right != null) { stack.Push(current.Right); } } }
7.从上往下遍历二元树[数据结构]
例如输入
8
6 10
5 7 9 11
输出8 6 10 5 7 9 11。
分析:这曾是微软的一道面试题。这道题实质上是要求遍历一棵二元树,只不过不是我们熟悉的前序、中序或者后序遍历。
我们从树的根结点开始分析。自然先应该打印根结点8,同时为了下次能够打印8的两个子结点,我们应该在遍历到8时把子结点6和10保存到一个数据容器中。现在数据容器中就有两个元素6 和10了。按照从左往右的要求,我们先取出6访问。打印6的同时要把6的两个子结点5和7放入数据容器中,此时数据容器中有三个元素10、5和7。接下来我们应该从数据容器中取出结点10访问了。注意10比5和7先放入容器,此时又比5和7先取出,就是我们通常说的先入先出。因此不难看出这个数据容器的类型应该是个队列。
既然已经确定数据容器是一个队列,现在的问题变成怎么实现队列了。实际上我们无需自己动手实现一个,因为STL已经为我们实现了一个很好的deque(两端都可以进出的队列),我们只需要拿过来用就可以了。
我们知道树是图的一种特殊退化形式。同时如果对图的深度优先遍历和广度优先遍历有比较深刻的理解,将不难看出这种遍历方式实际上是一种广度优先遍历。因此这道题的本质是在二元树上实现广度优先遍历。
static void Main(string[] args) { Node root = new Node() { Value = 8 }; root.Left = new Node() { Value = 6 }; root.Right = new Node() { Value = 10 }; root.Left.Left = new Node() { Value = 5 }; root.Left.Right = new Node() { Value = 7 }; root.Right.Left = new Node() { Value = 9 }; root.Right.Right = new Node() { Value = 11 }; EnuambleTree(root); }
static void EnuambleTree(Node node) { if (node == null) { return; } Queue<Node> queue = new Queue<Node>(); queue.Enqueue(node); while (queue.Count != 0) { Node current = queue.Dequeue(); Console.WriteLine(current.Value); if (current.Left != null) { queue.Enqueue(current.Left); } if (current.Right != null) { queue.Enqueue(current.Right); } } }
8.第一个只出现一次的字符[算法]
在一个字符串中找到第一个只出现一次的字符。如输入abaccdeff,则输出b。
分析:这道题是2006年google的一道笔试题。
看到这道题时,最直观的想法是从头开始扫描这个字符串中的每个字符。当访问到某字符时拿这个字符和后面的每个字符相比较,如果在后面没有发现重复的字符,则该字符就是只出现一次的字符。如果字符串有n个字符,每个字符可能与后面的O(n)个字符相比较,因此这种思路时间复杂度是O(n2)。我们试着去找一个更快的方法。
由于题目与字符出现的次数相关,我们是不是可以统计每个字符在该字符串中出现的次数?要达到这个目的,我们需要一个数据容器来存放每个字符的出现次数。在这个数据容器中可以根据字符来查找它出现的次数,也就是说这个容器的作用是把一个字符映射成一个数字。在常用的数据容器中,哈希表正是这个用途。
哈希表是一种比较复杂的数据结构。由于比较复杂,STL中没有实现哈希表,因此需要我们自己实现一个。但由于本题的特殊性,我们只需要一个非常简单的哈希表就能满足要求。由于字符(char)是一个长度为8的数据类型,因此总共有可能256 种可能。于是我们创建一个长度为256的数组,每个字母根据其ASCII码值作为数组的下标对应数组的对应项,而数组中存储的是每个字符对应的次数。这样我们就创建了一个大小为256,以字符ASCII码为键值的哈希表。
我们第一遍扫描这个数组时,每碰到一个字符,在哈希表中找到对应的项并把出现的次数增加一次。这样在进行第二次扫描时,就能直接从哈希表中得到每个字符出现的次数了。
static char FindFistAloneChar(string target) { var chararray = target.ToCharArray(); Dictionary<int, int> dic = new Dictionary<int, int>(); for (int i = 0; i < 256; i++) { dic[i] = 0; } foreach (var item in chararray) { int ascii = (int)item; dic[ascii] += 1; } foreach (var item in chararray) { int ascii = (int)item; if (dic[ascii] == 1) { return item; } } return char.MinValue; }
9.用两个栈实现队列
分析:从上面的类的声明中,我们发现在队列中有两个栈。因此这道题实质上是要求我们用两个栈来实现一个队列。相信大家对栈和队列的基本性质都非常了解了:栈是一种后入先出的数据容器,因此对队列进行的插入和删除操作都是在栈顶上进行;队列是一种先入先出的数据容器,我们总是把新元素插入到队列的尾部,而从队列的头部删除元素。
我们通过一个具体的例子来分析往该队列插入和删除元素的过程。首先插入一个元素a,不妨把先它插入到m_stack1。这个时候m_stack1中的元素有{a},m_stack2为空。再插入两个元素b和c,还是插入到m_stack1中,此时m_stack1中的元素有{a,b,c},m_stack2中仍然是空的。
这个时候我们试着从队列中删除一个元素。按照队列先入先出的规则,由于a比b、c先插入到队列中,这次被删除的元素应该是a。元素a存储在m_stack1中,但并不在栈顶上,因此不能直接进行删除。注意到m_stack2我们还一直没有使用过,现在是让m_stack2起作用的时候了。如果我们把m_stack1中的元素逐个pop出来并push进入m_stack2,元素在m_stack2中的顺序正好和原来在m_stack1中的顺序相反。因此经过两次pop和push之后,m_stack1为空,而m_stack2中的元素是{c,b,a}。这个时候就可以pop出m_stack2的栈顶a了。pop之后的m_stack1为空,而m_stack2的元素为{c,b},其中b在栈顶。
这个时候如果我们还想继续删除应该怎么办呢?在剩下的两个元素中b和c,b比c先进入队列,因此b应该先删除。而此时b恰好又在栈顶上,因此可以直接pop出去。这次pop之后,m_stack1中仍然为空,而m_stack2为{c}。
从上面的分析我们可以总结出删除一个元素的步骤:当m_stack2中不为空时,在m_stack2中的栈顶元素是最先进入队列的元素,可以pop出去。如果m_stack2为空时,我们把m_stack1中的元素逐个pop出来并push进入m_stack2。由于先进入队列的元素被压到m_stack1的底端,经过pop和push之后就处于m_stack2的顶端了,又可以直接pop出去。
接下来我们再插入一个元素d。我们是不是还可以把它push进m_stack1?这样会不会有问题呢?我们说不会有问题。因为在删除元素的时候,如果m_stack2中不为空,处于m_stack2中的栈顶元素是最先进入队列的,可以直接pop;如果m_stack2为空,我们把m_stack1中的元素pop出来并push进入m_stack2。由于m_stack2中元素的顺序和m_stack1相反,最先进入队列的元素还是处于m_stack2的栈顶,仍然可以直接pop。不会出现任何矛盾。
我们用一个表来总结一下前面的例子执行的步骤:
操作 |
m_stack1 |
m_stack2 |
append a |
{a} |
{} |
append b |
{a,b} |
{} |
append c |
{a,b,c} |
{} |
delete head |
{} |
{b,c} |
delete head |
{} |
{c} |
append d |
{d} |
{c} |
delete head |
{d} |
{} |
总结完push和pop对应的过程之后,我们可以开始动手写代码了。参考代码如下:
public class CustomQueue<T> { Stack<T> firstStack; Stack<T> secondStack; public CustomQueue() { firstStack = new Stack<T>(); secondStack = new Stack<T>(); } public void Enqueue(T item) { firstStack.Push(item); Count = Count + 1; } public T Deque() { if (secondStack.Count == 0) { while (firstStack.Count > 0) { secondStack.Push(firstStack.Pop()); } } Count = Count - 1; return secondStack.Pop(); } public int Count { get; private set; } }
CustomQueue<int> testQueue = new CustomQueue<int>(); testQueue.Enqueue(1); testQueue.Enqueue(2); testQueue.Enqueue(3); testQueue.Deque(); testQueue.Enqueue(4); testQueue.Enqueue(5); testQueue.Enqueue(6); testQueue.Deque(); testQueue.Deque(); testQueue.Enqueue(7); testQueue.Enqueue(8); while (testQueue.Count > 0) { Console.WriteLine(testQueue.Deque()); }
10.反转链表[数据结构]
输入一个链表的头结点,反转该链表,并返回反转后链表的头结点。链表结点定义如下:
struct ListNode { int m_nKey; ListNode* m_pNext; };
分析:这是一道广为流传的微软面试题。由于这道题能够很好的反应出程序员思维是否严密,在微软之后已经有很多公司在面试时采用了这道题。
为了正确地反转一个链表,需要调整指针的指向。与指针操作相关代码总是容易出错的,因此最好在动手写程序之前作全面的分析。在面试的时候不急于动手而是一开始做仔细的分析和设计,将会给面试官留下很好的印象,因为在实际的软件开发中,设计的时间总是比写代码的时间长。与其很快地写出一段漏洞百出的代码,远不如用较多的时间写出一段健壮的代码。
为了将调整指针这个复杂的过程分析清楚,我们可以借助图形来直观地分析。假设下图中l、m和n是三个相邻的结点:
a?b?…?l mànà…
假设经过若干操作,我们已经把结点l之前的指针调整完毕,这些结点的m_pNext指针都指向前面一个结点。现在我们遍历到结点m。当然,我们需要把调整结点的m_pNext指针让它指向结点l。但注意一旦调整了指针的指向,链表就断开了,如下图所示:
a?b?…l?m nà…
因为已经没有指针指向结点n,我们没有办法再遍历到结点n了。因此为了避免链表断开,我们需要在调整m的m_pNext之前要把n保存下来。
接下来我们试着找到反转后链表的头结点。不难分析出反转后链表的头结点是原始链表的尾位结点。什么结点是尾结点?就是m_pNext为空指针的结点。
基于上述分析,我们不难写出如下代码:
ChainNode<int> node = new ChainNode<int>() { Value = 0 }; node.Next = new ChainNode<int>() { Value = 1 }; node.Next.Next = new ChainNode<int>() { Value = 2 }; node.Next.Next.Next = new ChainNode<int>() { Value = 3 }; node.Next.Next.Next.Next = new ChainNode<int>() { Value = 4 }; ReverseSingleChainNode(node);
static ChainNode<T> ReverseSingleChainNode<T>(ChainNode<T> node) { ChainNode<T> pReversedHead = null; ChainNode<T> current = node; ChainNode<T> previous = null; while (current != null) { var next = current.Next; if (next == null) { pReversedHead = current; } current.Next = previous; previous = current; current = next; } return pReversedHead; }
11.二元树的深度[数据结构]
分析:这道题本质上还是考查二元树的遍历。
题目给出了一种树的深度的定义。当然,我们可以按照这种定义去得到树的所有路径,也就能得到最长路径以及它的长度。只是这种思路用来写程序有点麻烦。
我们还可以从另外一个角度来理解树的深度。如果一棵树只有一个结点,它的深度为1。如果根结点只有左子树而没有右子树,那么树的深度应该是其左子树的深度加1;同样如果根结点只有右子树而没有左子树,那么树的深度应该是其右子树的深度加1。如果既有右子树又有左子树呢?那该树的深度就是其左、右子树深度的较大值再加1。
目:输入一棵二元树的根结点,求该树的深度。从根结点到叶结点依次经过的结点(含根、叶结点)形成树的一条路径,最长路径的长度为树的深度。
Node root = new Node() { Value = 8 }; root.Left = new Node() { Value = 6 }; root.Right = new Node() { Value = 10 }; root.Left.Left = new Node() { Value = 5 }; root.Left.Right = new Node() { Value = 7 }; root.Right.Left = new Node() { Value = 9 }; root.Right.Right = new Node() { Value = 11 }; static int GetDepthOfTree(Node node) { if (node == null) { return 0; } int leftDepth = GetDepthOfTree(node.Left); int rightDepth = GetDepthOfTree(node.Right); int maxDepth = Math.Max(rightDepth, rightDepth); return maxDepth + 1; }
12 从尾到头输出链表[数据结构]
输入一个链表的头结点,从尾到头反过来输出每个结点的值。链表结点定义如下:
分析:这是一道很有意思的面试题。该题以及它的变体经常出现在各大公司的面试、笔试题中。
看到这道题后,第一反应是从头到尾输出比较简单。于是很自然地想到把链表中链接结点的指针反转过来,改变链表的方向。然后就可以从头到尾输出了。反转链表的算法详见本人面试题精选系列的第19题,在此不再细述。但该方法需要额外的操作,应该还有更好的方法。
接下来的想法是从头到尾遍历链表,每经过一个结点的时候,把该结点放到一个栈中。当遍历完整个链表后,再从栈顶开始输出结点的值,此时输出的结点的顺序已经反转过来了。该方法需要维护一个额外的栈,实现起来比较麻烦。
既然想到了栈来实现这个函数,而递归本质上就是一个栈结构。于是很自然的又想到了用递归来实现。要实现反过来输出链表,我们每访问到一个结点的时候,先递归输出它后面的结点,再输出该结点自身,这样链表的输出结果就反过来了。
基于这样的思路,不难写出如下代码:
ChainNode<int> node = new ChainNode<int>() { Value = 0 }; node.Next = new ChainNode<int>() { Value = 1 }; node.Next.Next = new ChainNode<int>() { Value = 2 }; node.Next.Next.Next = new ChainNode<int>() { Value = 3 }; node.Next.Next.Next.Next = new ChainNode<int>() { Value = 4 }; PrintReverseNode(node);
static void PrintReverseNode(ChainNode<int> node) { if (node != null) { PrintReverseNode(node.Next); Console.WriteLine(node.Value); } }
13: 树的子结构[数据结构]
输入两棵二叉树A和B,判断树B是不是A的子结构。
例如,下图中的两棵树A和B,由于A中有一部分子树的结构和B是一样的,因此B就是A的子结构。
分析:这是2010年微软校园招聘时的一道题目。二叉树一直是微软面试题中经常出现的数据结构。对微软有兴趣的读者一定要重点关注二叉树。
回到这个题目的本身。要查找树A中是否存在和树B结构一样的子树,我们可以分为两步:第一步在树A中找到和B的根结点的值一样的结点N,第二步再判断树A中以N为根结点的子树是不是包括和树B一样的结构。
第一步在树A中查找与根结点的值一样的结点。这实际上就是树的遍历。对二叉树这种数据结构熟悉的读者自然知道我们可以用递归的方法去遍历,也可以用循环的方法去遍历。由于递归的代码实现比较简洁,面试时如果没有特别要求,我们通常都会采用递归的方式。
static bool EnuamateTree(Node node, Node subNode) { if (node == null) { return false; } if (node != null && node.Value == subNode.Value) { return IsSubTree(subNode, node); } bool isLeft = EnuamateTree(node.Left, subNode); bool isRight = EnuamateTree(node.Right, subNode); return isLeft || isRight; } static bool IsSubTree(Node subNode, Node parentNode) { if (subNode == null && parentNode == null) { return true; } if (subNode == null && parentNode != null) { return true; } if (subNode != null && parentNode == null) { return false; } if (subNode.Value != parentNode.Value) { return false; } bool IsLeftSub = IsSubTree(subNode.Left, parentNode.Left); bool IsRightSub = IsSubTree(subNode.Right, parentNode.Right); return IsLeftSub && IsRightSub; }
14:判断二叉树是不是平衡[数据结构]
题目:输入一棵二叉树的根结点,判断该树是不是平衡二叉树。如果某二叉树中任意结点的左右子树的深度相差不超过1,那么它就是一棵平衡二叉树。例如下图中的二叉树就是一棵平衡二叉树:
在本系列博客的第27题,我们曾介绍过如何求二叉树的深度。有了求二叉树的深度的经验之后再解决这个问题,我们很容易就能想到一个思路:在遍历树的每个结点的时候,调用函数TreeDepth得到它的左右子树的深度。如果每个结点的左右子树的深度相差都不超过1,按照定义它就是一棵平衡的二叉树。这种思路对应的代码如下:
bool IsBalanced(BinaryTreeNode* pRoot)
{
if(pRoot == NULL)
return true;
int left = TreeDepth(pRoot->m_pLeft);
int right = TreeDepth(pRoot->m_pRight);
int diff = left - right;
if(diff > 1 || diff < -1)
return false;
return IsBalanced(pRoot->m_pLeft) && IsBalanced(pRoot->m_pRight);
}
上面的代码固然简洁,但我们也要注意到由于一个节点会被重复遍历多次,这种思路的时间效率不高。例如在函数IsBalance中输入上图中的二叉树,首先判断根结点(值为1的结点)的左右子树是不是平衡结点。此时我们将往函数TreeDepth输入左子树根结点(值为2的结点),需要遍历结点4、5、7。接下来判断以值为2的结点为根结点的子树是不是平衡树的时候,仍然会遍历结点4、5、7。毫无疑问,重复遍历同一个结点会影响性能。接下来我们寻找不需要重复遍历的算法。
如果我们用后序遍历的方式遍历二叉树的每一个结点,在遍历到一个结点之前我们已经遍历了它的左右子树。只要在遍历每个结点的时候记录它的深度(某一结点的深度等于它到叶节点的路径的长度),我们就可以一边遍历一边判断每个结点是不是平衡的。下面是这种思路的参考代码:
bool IsBalanced(BinaryTreeNode* pRoot, int* pDepth)
{
if(pRoot == NULL)
{
*pDepth = 0;
return true;
}
int left, right;
if(IsBalanced(pRoot->m_pLeft, &left)
&& IsBalanced(pRoot->m_pRight, &right))
{
int diff = left - right;
if(diff <= 1 && diff >= -1)
{
*pDepth = 1 + (left > right ? left : right);
return true;
}
}
return false;
}
我们只需要给上面的函数传入二叉树的根结点以及一个表示结点深度的整形变量就可以了:
bool IsBalanced(BinaryTreeNode* pRoot)
{
int depth = 0;
return IsBalanced(pRoot, &depth);
}
在上面的代码中,我们用后序遍历的方式遍历整棵二叉树。在遍历某结点的左右子结点之后,我们可以根据它的左右子结点的深度判断它是不是平衡的,并得到当前结点的深度。当最后遍历到树的根结点的时候,也就判断了整棵二叉树是不是平衡二叉树了。
static bool HasTwoChildren(Node node) { if (node.Left != null && node.Right != null) { return true; } return false; } static bool IsBalanceTree(Node node) { if (node == null) { return true; } if (!HasTwoChildren(node)) { if (node.Left == null && node.Right != null && (node.Right.Left != null || node.Right.Right != null)) { return false; } if (node.Right == null && node.Left != null && (node.Left.Left != null || node.Left.Right != null)) { return false; } return true; } else { bool isLeft = IsBalanceTree(node.Left); bool isRight = IsBalanceTree(node.Right); return isLeft && isRight; } }
15:题目:某公司有几万名员工,请完成一个时间复杂度为O(n)的算法对该公司员工的年龄作排序,可使用O(1)的辅助空间。
分析:排序是面试时经常被提及的一类题目,我们也熟悉其中很多种算法,诸如插入排序、归并排序、冒泡排序,快速排序等等。这些排序的算法,要么是O(n2)的,要么是O(nlogn)的。可是这道题竟然要求是O(n)的,这里面到底有什么玄机呢?
题目特别强调是对一个公司的员工的年龄作排序。员工的数目虽然有几万人,但这几万员工的年龄却只有几十种可能。上班早的人一般也要等到将近二十岁才上班,一般人再晚到了六七十岁也不得不退休。
由于年龄总共只有几十种可能,我们可以很方便地统计出每一个年龄里有多少名员工。举个简单的例子,假设总共有5个员工,他们的年龄分别是25、24、26、24、25。我们统计出他们的年龄,24岁的有两个,25岁的也有两个,26岁的一个。那么我们根据年龄排序的结果就是:24、24、25、25、26,即在表示年龄的数组里写出两个24、两个25和一个26。
想明白了这种思路,我们就可以写出如下代码:
void SortAges(int ages[], int length)
{
if(ages == NULL || length <= 0)
return;
const int oldestAge = 99;
int timesOfAge[oldestAge + 1];
for(int i = 0; i <= oldestAge; ++ i)
timesOfAge[i] = 0;
for(int i = 0; i < length; ++ i)
{
int age = ages[i];
if(age < 0 || age > oldestAge)
throw new std::exception("age out of range.");
++ timesOfAge[age];
}
int index = 0;
for(int i = 0; i <= oldestAge; ++ i)
{
for(int j = 0; j < timesOfAge[i]; ++ j)
{
ages[index] = i;
++ index;
}
}
}
在上面的代码中,允许的范围是0到99岁。数组timesOfAge用来统计每个年龄出现的次数。某个年龄出现了多少次,就在数组ages里设置几次该年龄。这样就相当于给数组ages排序了。该方法用长度100的整数数组辅助空间换来了O(n)的时间效率。由于不管对多少人的年龄作排序,辅助数组的长度是固定的100个整数,因此它的空间复杂度是个常数,即O(1)。