4.剑指Offer --- 解决面试题的思路
Posted enlyhua
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了4.剑指Offer --- 解决面试题的思路相关的知识,希望对你有一定的参考价值。
第4章 解决面试题的思路
4.1 面试官谈面试思路
编码之前先讲自己的思路。
4.2 画图让抽象问题形象化
图形能使抽象的问题具体化,形象化。
题目19:二叉树的镜像
题目:请完成一个函数,输入一个二叉树,该函数输出它的镜像。
二叉树结点的定义如下:
struct BinaryTreeNode {
int m_nValue;
BinaryTreeNode* m_pLeft;
BinaryTreeNode* m_pRight;
};
思路:
先前序遍历这棵树的每个结点,如果遍历到的结点有子结点,就交换它的两个子结点。当交换完所有非叶子结点的左右子结点之后,就得到了树的镜像。
void MirrorRecursively(BinaryTreeNode* pNode)
{
if (pNode == null) {
return;
}
if (pNode->m_pLeft == null && pNode->m_pRight == null) {
return;
}
//交换左右子树
BinaryTreeNode* pTemp = pNode->m_pLeft;
pNode->m_pLeft = pNode->m_pRight;
pNode->m_pRight = pTemp;
if (pNode->m_pLeft) {
MirrorRecursively(pNode->m_pLeft);
}
if (pNode->m_pRight) {
MirrorRecursively(pNode->m_pRight);
}
}
面试题20:顺时针打印矩阵
题目:输入一个矩阵,按照从外往里以顺时针的顺序依次打印出每一个数字。例如:如果输入如下矩阵:
1 2 3 4
5 6 7 8
9 10 11 12
13 14 15 16
则依次打印出 1,2,3,4,8,12,16,15,14,13,9,5,6,7,11,10
分析循环条件:
假设这个矩阵的行数是rows,列数是columns。打印第一圈的左上角的坐标是(0,0),第二圈左上角的坐标是(1,1),以此类推。我们注意到,
左上角的坐标中行标和列标总是相等的,于是可以在矩阵中选取左上角(start,start)的一圈作为我们分析的目标。
对于一个5*5的矩阵而言,最后一圈只有数字,对应的坐标为(2,2)。我们发现 5>2*2。对于一个6*6的矩阵而言,最后一圈有4个数字,其左上角
的坐标仍为(2,2)。我们发现 6>2*2依然成立。于是我们可以得出,让循环继续的条件是 columns>startX*2并且rows>startY*2。
void PrintMatrixClockwisely(int** numbers, int columns, int rows)
{
if (numbers == null || columns <= 0 || rows >= 0) {
return;
}
int start = 0;
while (columns > start * 2 && rows > start * 2) {
PrintMatrixInCircle(numbers, columns, rows, start);
start++;
}
}
接着我们考虑如何打印一圈的功能,即如何实现 PrintMatrixInCircle。我们可以把打印一圈分为4步:第一步从左到右打印一行,第二步从上到下打印
一行,第三步从右到左打印一行,第四步从下到上打印一列。每一步我们根据起始坐标和终止坐标用一个循环就能打印出一行或者一列。
不过值得注意的是,最后一圈可能退化成只有一行,只有一列,甚至只有一个数字,因此打印这样的一圈就不需要四步。
因此我们需要分析打印时每一步的前提条件。第一步总是需要的,因为打印一圈至少有一步。如果只有一行,那么就不用第二步。也就是需要第二步的前提是
终止行号大于起始行号。需要第三步打印的前提条件是圈内至少有两行两列,也就是说除了要求终止行号大于起始行号之外,还要求终止列号大于起始列号。同理,
需要打印第四步的前提条件是至少有三行两列,因此要求终止行号比起始行号至少大2,同时终止列号大于起始列号。
void PrintMatrixInCircle(int** numbers, int columns, int rows, int start)
{
int endX = columns - 1 - start;
int endY = rows - 1 - start;
//从左往右打印一行
for (int i = start; i <= endX; i++) {
int number = numbers[start][i];
printNumber(number);
}
//从上到下打印一列
for (start < endY) {
for (int i = start + 1; i <= endY; i++) {
int number = numbers[i][endX];
printNumber(number);
}
}
//从右往左打印一行
if (start < endX && start < endY) {
for (int i = endX - 1; i >= start; i--) {
int number = numbers[endY][i];
printNumber(number);
}
}
//从下到上打印一列
if (start < endX && start < endY - 1) {
for (int i = endY - 1; i >= start; i--) {
int number = numbers[i][start];
if (start < endX && start < endY) {
}
}
}
4.3 举例让抽象问题具体化
面试题21:包含 min 函数的栈
题目:定义栈的数据结构,请在该类型中实现一个能够得到栈的最小元素的min函数。在该栈中,调用min,push及pop的时间复杂度都是O(1)。
首先往空的数据栈压入数字3,显然现在3是最小值,我们也把这个值压入辅助栈。接下来往数据栈压入数字4.由于4大于之前的最小值,因此我们仍然需要往辅助栈压入数字3.
第三步继续往数据栈压入数字2。由于2小于之前的3,因此我们把最小值更新为2,并把2压入辅助栈。同样,当压入数字1时,也更新最小值,并把最新的1压入辅助栈。
从上面可以看出来,如果每次都把最小元素压入辅助栈,那么就能保证辅助栈的栈顶一直是最小元素。当最小元素从数据栈里被弹出的时候,同时弹出辅助栈的栈顶元素,此时
辅助栈的新栈顶元素就是下一个最小值。
//m_data 是数据栈,m_min 是辅助栈
template <typename T> void StackWithMin<T>::push(const T& value)
{
m_data.push(value);
if (m_min.size() == 0 || value < m_min.top())
m_min.push(value);
else
m_min.push(m_min.top());
}
template <typename T> void StackWithMin<T>::pop()
{
assert(m_data.size() > 0 && m_min.size() > 0);
m_data.pop();
m_min.pop();
}
template <typename T> const T& StackWithMin(T::min() const
{
assert(m_data.size() > 0 && m_min.size() > 0);
return m_min.top();
}
面试题22:栈的压入,弹出序列
题目:输入两个整数序列,第一个序列表示栈的压入顺序,请判断第二个序列是否为该栈的弹出顺序。假设压入栈的所有数字均不相等。例如序列1,2,3,4,5是某栈的压栈
序列,序列4,5,3,2,1是该压栈序列对应的一个弹出序列,但4,3,5,1,2 就不可能是该压栈序列的弹出序列。
解决这个问题很直观的想法就是建立一个辅助栈,把输入的第一个序列中的数字依次压入该辅助栈,并按照第二个序列的顺序依次从该栈中弹出数字。
规律:
如果下一个弹出的数字刚好是栈顶数字,那么直接弹出。如果下一个弹出的数字不在栈顶,我们把压栈序列中还没有入栈的数字压入辅助栈,直到把下一个需要弹出的数字压入
栈顶为止。如果所有的数字都压入了仍然没有找到下一个弹出的数字,那么该序列就不可能是一个弹出序列。
// int 数组指针 pPush,pPop
bool IsPopOrder(const int* pPush, const int* pPop, int nLength)
{
bool bPossible = false;
if (pPush != null && pPop != null && nLength > 0) {
const int* pNextPush = pPush;
const int* pNextPop = pPop;
std::stack<int>stackData;
while (pNextPop - pPop < nLength) {
while (stackData.empty() || stackData.top() != *pNextPop) {
if (pNextPush - pPush == nLength)
break;
stackData.push(*pNextPush);
pNextPush++;
}
if (stackData.top() != *pNextPop)
break;
stackData.pop();
pNextPop++;
}
if (stackData.empty() && pNextPop - pPop == nLength) {
bPossible = true;
}
}
return bPossible;
}
面试题23:从上往下打印二叉树
题目:从上往下打印出二叉树的每个结点,同一层的结点按照从左往右的顺序打印。例如输入8,6,10,5,7,9,11(按层输入),依次打印出 8,6,10,5,7,9,11
二叉树结点定义如下:
struct BinaryTreeNode {
int m_nValue;
BinaryTreeNode* m_pLeft;
BinaryTreeNode* m_pRight;
};
分析:
这种考察的是树的遍历算法,只是这种遍历不是我们熟悉的 前序,中序,后序遍历。
规律:
每一次打印一个结点的时候,如果该结点有子节点,则把该结点的子节点放到一个队列的末尾。接下来到队列的头部取出最早进入队列的结点,重复前面的打印操作,
直到队列中所有的结点都被打印出来为止。
void PrintFromTopToButton(BinaryTreeNode* pTreeRoot)
{
if (!pTreeRoot)
return;
std::deque<BinaryTreeNode*> dequeTreeNode;
dequeTreeNode.push_back(pTreeRoot);
while (dequeTreeNode.size()) {
BinaryTreeNode* pNode = dequeTreeNode.front();
dequeTreeNode.pop_front();
printf("%d ", pNode->m_nValue);
if (pNode->m_pLeft) {
dequeTreeNode.push_back(pNode->m_pLeft);
}
if (pNode->m_pRight) {
dequeTreeNode.push_back(pNode->m_pRight);
}
}
}
本题扩展:
如何广度遍历一个有向图?这同样也可以基于队列实现。树是图的一种特殊退化形式,从上到下按层遍历二叉树,从本质上来说就是广度优先遍历二叉树。
举一反三:
不管是广度优先遍历一个有向图还是一棵树,都要用到队列。第一步我们把起始点(对树而言是根结点)放入队列中。接下来每一次从队列的头部取出一个结点,遍历这个
结点之后把从它能到达的结点(对树而言是子节点)都依次放入队列。我们重复这个遍历过程,直到队列中的结点都被遍历为止。
面试题24:二叉搜索树的后序遍历序列
题目:输入一个整数数组,判断该数组是不是某二叉搜索树的后续遍历的结果。如果是则返回true,否则返回false。假设输入的数组的任意两个数字都互不相同。
分析:
在后序遍历中,最后一个数字是树的根结点的值。数组中前面的数字可以分为两部分:第一部分是左子树结点的值,它们都比根结点的值小;第二部分是右子树结点的值,
它们都比根结点的值大。
bool VerifySquenceOfBST(int sequence[], int length)
{
if (sequence == null || length <= 0)
return false;
int root = sequence[length - 1];
//在二叉树中左子树的结点小于根结点。
int i = 0;
for ( ; i < length - 1; i++) {
if (sequence[i] > root) {
break;
}
}
//在二叉搜索树中右子树的结点大于根结点
int j = i;
for ( ; j < length - 1; j++) {
if (sequence[i] < root) {
return false;
}
}
//判断左子树是不是二叉搜索树
bool left = true;
if (i > 0) {
left = VerifySquenceOfBST(sequence, i);
}
//判断右子树是不是二叉搜索树
bool right = true;
if (i < length - 1) {
right = VerifySquenceOfBST(sequence + i, length - i - 1);
}
return (left && right);
}
相关题目:
输入一个整数数组,判断该数组是不是某二叉搜索树的前序遍历的结果。这和前面的问题的后序遍历很相似,只是在前序遍历得到的序列中,第一个数字是根结点的值。
举一反三:
如果面试题要求是处理一颗二叉树的遍历序列,我们可以先找到二叉树的根结点,再基于根结点把整个树的遍历序列拆分成左子树对应的子序列和右子树对应的子序列,
接下来再递归的处理这2个子序列。
面试题25:二叉树中和为某一值的路径
题目:输入一个二叉树和一个整数,打印出二叉树中结点值的和为输入整数的所有路径。从树的根结点开始往下一直到叶节点所经过的结点形成一条路径。二叉树结点的定义如下:
sruct BinaryTreeNode {
int m_nValue;
BinaryTreeNode* m_pLeft;
BinaryTreeNode* m_pRight;
};
分析:
当用前序遍历的方式访问到某一节点的时候,我们把该结点添加到路径上,并累加该结点的值。如果该节点是叶结点并且路径中结点值刚好等于输入的整数,则当前的路径符合
要求,我们打印出来。如果当前结点不是叶结点,则继续访问它的子结点。当前结点访问结束后,递归函数将自动回到它的父结点。因此我们在函数退出之前要在路径上删除当前结点
并减去当前结点的值,以确保返回父结点时路径刚好是从根结点到父结点的路径。我们不难看出保存路径的数据结构实际上是一个栈,因为路径要与递归调用的状态一致,而递归调用的
本质就是一个压栈和出栈的过程。
void FindPath(BinaryTreeNode* pRoot, int expectedSum)
{
if (pRoot == null)
return ;
std::vector<int> path;
int currentSum = 0;
FindPath(pRoot, expectedSum, path, currentSum);
}
void FindPath(BinaryTreeNode* pRoot, int expectedSum, std::vector<int>& path, int currentSum)
{
currentSum += pRoot->m_nValue;
path.push_back(pRoot->m_nValue);
//如果是叶子结点,并且路径上的结点之和等于输入的值,打印出来
bool isLeaf = pRoot->m_pLeft == null && pRoot->m_pRight == null;
if (currentSum == expectedSum && isLeaf) {
printf("A path is found: ");
std::vector<int>::iterator iter = path.begin();
for ( ; iter != path.end(); iter++) {
printf("%d \\t", *iter);
}
printf("\\n");
}
//如果不是叶子结点,则遍历它的子结点
if (pRoot->m_pLeft != null) {
FindPath(pRoot->m_pLeft, expectedSum, path, currentSum);
}
if (pRoot->m_pRight != null) {
FindPath(pRoot->m_pRight, expectedSum, path, currentSum);
}
//返回到父结点之前,在路径上删除当前结点
path.pop_back();
}
4.4 分解让复杂问题简单化
面试题26:复杂链表的复制
题目:请实现函数 ComplexListNode* Clone(ComplexListNode* pHead) 复制一个复杂的链表。在复杂链表中,每个结点除了有一个m_pNext 指针指向下一个结点外,
还有一个m_pSibling 指向链表中的任意结点或者 null。结点的C++定义如下:
struct ComplexListNode {
int m_nValue;
ComplexListNode* m_pNext;
ComplexListNode* m_pSibling;
};
注:
复杂链表的结点中,除了有指向下一个结点的指针,还有指向任意结点的指针。
第一种方法:
第一步是复制原始链表上的每个结点,并用m_pNext连接起来;第二步是设置每个结点的m_pSibling指针。假设原始链表中的某个结点N的m_pSibling指向结点S,由于
S的位置在链表中可能在N前面也可能在N的后面,所以要定位S的位置需要从原始链表的头结点开始找。对于一个含有n个结点的链表,由于定位每个结点的m_pSibling 都需要从
链表头结点开始经过O(n)步才能找到,时间复杂度是O(n^2)。
第二种方法:
第一步还是复制原始链表上的每个结点N创建N1,然后把这些创建出来的结点用m_pNext链接起来。同时我们把<N,N1>的配对信息放到一个哈希表中。第二步还是设置复制链表
上每个结点的m_pSibling。如果在原始链表中结点N的m_pSibling指向结点S,那么在复制链表中,对应的N1应该指向S1。由于有了哈希表,我们可以用O(1)的时间根据S找到S1。
这种相当于时间换空间。
第三种方法:
第一步仍然是根据原始链表的每个结点N创建对应的N1。这一次,我们把N1链接放在N的后面。第二步,设置复制出来的结点m_pSibling。第三步,把这个长链表拆分成两个
链表:把奇数位置的结点用 m_pNext 链接起来就是原始链表,把偶数位置的结点用 m_pNext 链接起来就是复制出来的链表。
ComplexListNode* Clone(ComplexListNode* pHead)
{
CloneNodes(pHead);
ConnectSiblingNodes(pHead);
return ReconnectNodes(pHead);
}
void CloneNodes(ComplexListNode* pHead)
{
ComplexListNode* pNode = pHead;
while (pNode != null) {
ComplexListNode* pCloned = new ComplexListNode();
pCloned->m_nValue = pNode->m_nValue;
pCloned->m_pNext = pNode->m_pNext;
pCloned->m_pSibing = null;
pNode->m_pNext = pCloned;
pNode = pCloned->m_pNext;
}
}
void ConnectSiblingNodes(ComplexListNode* pHead)
{
ComplexListNode* pNode = pHead;
while (pNode != null) {
ComplexListNode* pCloned = pNode->m_pNext;
if (pNode->m_pSibling != null) {
pCloned->m_pSibling = pNode->m_pSibling->m_pNext;
}
pNode = pCloned->m_pNext;
}
}
void ReconnectNodes(ComplexListNode* pHead)
{
ComplexListNode* pNode = pHead;
ComplexListNode* pClonedHead = null;
ComplexListNode* pClonedNode = null;
if (pNode != null) {
pClonedHead = pClonedNode = pNode->m_pNext;
pNode->m_pNext = pClonedNode->m_pNext;
pNode = pNode->m_pNext;
}
while (pNode != null) {
pClonedNode->m_pNext = pNode->m_pNext;
pClonedNode = pClonedNode->m_pNext;
pNode->m_pNext = pClonedNode->m_pNext;
pNode = pNode->m_pNext;
}
return pClonedHead;
}
面试题27:二叉搜索树与双向链表
题目:输入一颗二叉搜索树,将该二叉搜索树转换成一个排序的双向链表。要求不能创建任何新的结点,只能调整该树中结点指针的指向。二叉树结点定义如下:
struct BinaryTreeNode {
int m_nValue;
BinaryTreeNode* m_pLeft;
BinaryTreeNode* m_pRight;
};
分析:
在二叉搜索树中,左子节点的值总是小于父结点的值,右子结点的值总是大于父结点的值。因此我们在转换成排序双向链表的时候,原先指向左子节点的指针调整为链表
中指向前一个结点的指针,原先指向右子结点的指针调整为链表中指向后一个节点的指针。
BinaryTreeNode* Convert(BinaryTreeNode* pRootOfTree)
{
BinaryTreeNode* pLastNodeInList;
ConvertNode(pRootOfTree, &pLastNodeInList);
//pLastNodeInList指向双向链表的尾结点;我们需要返回头结点
BinaryTreeNode* pHeadOfList = pLastNodeInList;
while (pHeadOfList != null && pHeadOfList->m_pLeft != null) {
pHeadOfList = pHeadOfList->m_pLeft;
}
return pHeadOfList;
}
void ConvertNode(BinaryTreeNode* pNode, BinaryTreeNode** pLastNodeInList)
{
if (pNode == null)
return;
BinaryTreeNode* pCurrent = pNode;
if (pCurrent->m_pLeft != null) {
ConvertNode(pCurrent->m_pLeft, pLastNodeInLast);
}
pCurrent->m_pLeft = *pLastNodeInList;
if (*pLastNodeInList != null) {
(*pLastNodeInList)->m_pRight = pCurrent;
}
*pLastNodeInList = pCurrent;
if (pCurrent->m_pRight != null) {
ConvertNode(pCurrent->m_pRight, pLastNodeInList);
}
}
面试题28:字符串的排列
题目:输入一个字符串,打印出该字符串中字符的所有排序。例如输入字符串 "abc",则打印出字符a,b,c 所能排列出来的所有字符串 abc,acb,bac,bca,cab,cba。
分析:
把复杂的问题分解成小问题。比如,我们把第一个字符串看成两部分组成:第一部分为它的第一个字符,第二部分是后面所有字符。
我们求整个字符串的排列,可以看成两步:首先求所有可能出现在第一个位置的字符,即把第一个字符和后面所有的字符交换。第二步固定第一个字符,求后面所有字符的排列。
这个时候我们仍把后面的所有字符分成两个部分。
void Permutation(char* pStr)
{
if (pStr == null)
return;
Permutation(pStr, pStr);
}
void Permutation(char* pStr, char* pBegin)
{
if (*pBegin == '\\0') {
printf("%s \\n", pStr);
} else {
for (char* pCh = pBegin; *pCh != '\\0'; pCh++) {
char temp = *pCh;
*pCh = *pBegin;
*pBegin = temp;
Permutation(pStr, pBegin + 1);
temp = *pCh;
*pCh = *pBegin;
*pBegin = temp;
}
}
}
在函数 Permutation(char* pStr, char* pBegin) 中,指针pStr指向整个字符串的第一个字符,pBegin 指向当前我们做排列操作的字符串的第一个字符。在每一次
递归的时候,我们从 pBegin 向后扫描每一个字符(即指针 pCh 指向的字符)。在交换 pBegin 和 pCh 指向的字符后,我们再对 pBegin 后面的字符串递归的做排列操作,直至
pBegin 指向字符串的末尾。
本题扩展:
如果不是要求字符的所有排列,而是字符的所有组合,应该怎么办?输入a,b,c, 则它的组合有 a,b,c,ab,ac,bc,abc。当交换字符串中的两个字符时,虽然能得到两个
不同的排列,但却是同一个组合。比如ab和ba是不同的排列,但只算一个组合。
如果输入n个字符,则这n个字符能构成长度为1的组合,长度为2的组合,... 长度为n的组合。在求n个字符的长度为m(1<= m <= n)的组合的时候,我们把这n个字符分成
2个部分:第一个字符和其余所有的字符。如果组合里面包含第一个字符,则下一步在剩余的字符里选取 m - 1 个字符;如果组合里不包含第一个字符,则下一步在剩余的 n - 1
个字符里面选取m个字符。也就是说,我们可以把求n个字符组成长度为m的组合的问题分解成两个子问题,分别求n-1个字符串长度为m-1的组合,以及求n-1个字符的长度为m的组合,
这两个子问题都可以用递归的方式解决。
相关题目:
1.输入一个含有8个数字的数组,判断有没有可能把这8个数字分别放到正方体的8个顶点上,使得正方体上三组相对的面上的4个顶点的和都相等。
2.在8*8的国际象棋上摆放8个皇后,使其不能互相攻击,即任意两个皇后不能处在同一行,同一列或者同一对角线上。请问总共有多少种符合条件的摆法?
举一反三:
如果面试题是按照一定要求摆放若干个数字,我们可以先求出这些数字的所有排列,然后再一一判断每个排列是不是满足题目给定的要求。
以上是关于4.剑指Offer --- 解决面试题的思路的主要内容,如果未能解决你的问题,请参考以下文章