欢迎访问我的博客首页。
解决面试题的思路
1. 画图让抽象问题形象化
1.1 二叉树的镜像
题目:请实现一个函数,输入一颗二叉树,输出它的镜像。二叉树与它的镜像如下图所示:
图 1 图 1 图1
分析:从图 1 不难发现,依次交换每个结点的左右子树就可以得到一颗树的镜像,所以遍历每一个结点时交换它的子树即可。但因为操作本身是交换,所以请注意中序遍历。以图 1 中的右图为例:遍历根节点 A 前处理左子树 B,遍历 A 结点时把左子树 B 作为右子树,然后再处理右子树,这样就处理了两次 B 而没有处理 A。综上所述可以实现先序、后序、层序等遍历方法。
// 代码1。
TreeNode* invertTree(TreeNode* root) {
if (root == nullptr)
return nullptr;
TreeNode* temp;
temp = invertTree(root->right);
root->right = invertTree(root->left);
root->left = temp;
return root;
}
// 代码2。
TreeNode* invertTree(TreeNode* root) {
if (root == nullptr)
return nullptr;
TreeNode *lchild, *rchild;
lchild = invertTree(root->right);
rchild = invertTree(root->left);
root->left = lchild;
root->right = rchild;
return root;
}
// 代码3。
TreeNode* invertTree(TreeNode* root) {
if (root == nullptr)
return nullptr;
invertTree(root->right);
invertTree(root->left);
swap(root->left, root->right);
return root;
}
代码:注意交换两个子树时需要用到第 3 个变量,这和交换两个普通变量一样。代码 1 中使用第 3 个变量 temp 实现交换,代码 2 使用了两个额外变量。代码 3 更适合形参是根节点指针的指针或引用类型的形参,这样就不需要返回值。代码 3 虽然有返回值,但函数 invertTree 调用自身的返回值都没有被使用,如第 26、27 行。
1.2 对称的二叉树
题目:输入一颗二叉树,判断它是不是对称的二叉树。如果二叉树和它的镜像一样,它就是对称的二叉树。如图 2 的左图的二叉树是对称的,中图和右图是不对称的。
图
2
图 2
图2
分析:判断二叉树是否对称的基本操作是比较结点的值是否相等,而关键就是谁和谁比较。从图 2 的左图可以发现,判断的过程是这样的:从根节点开始比较左右子节点,然后比较左子节点的左节点和右子节点的右节点、左子节点的右节点和右子节点的左节点…直到出现空结点或出现不相等。
bool equal(TreeNode* a, TreeNode* b) {
if (a == nullptr && b == nullptr)
return true;
if (a == nullptr || b == nullptr)
return false;
if (a->val != b->val)
return false;
return equal(a->left, b->right) && equal(a->right, b->left);
}
bool isSymmetric(TreeNode* root) {
if (root == nullptr)
return false;
return equal(root->left, root->right);
}
代码:切记第 6 行不能放在前面,因为通过指针使用成员引用变量符时要确保指针非空。
1.3 顺时针打印矩阵
题目:输入一个矩阵,按从外到内,以顺时针打印矩阵。例如图 3 的矩阵打印的顺序为 {1,2,3,4,8,12,16,15,14,13,9,5,6,7,11,10}。
图
3
图 3
图3
分析:从图中我们可以看出,遍历一周分别向 4 个方向移动,比如打印 {1,2,3,4} 是向右移动,打印 {4,8,12,16} 是向下移动…我们只需要控制遍历时的移动范围就行了,而移动范围又是递减的。
2. 举例让抽象问题具体化
2.1 包含 min 函数的栈
题目:定义栈,实现 3 个成员函数 min、push、pop,它们的时间复杂度都是 O(1),其中 min 函数返回栈内最小的元素。
分析:定义一个辅助栈,每次向主栈添加元素后,向辅助栈添加一个主栈中最小的元素。调用 min 函数时返回辅助栈的栈顶元素。
2.2 栈的压入、弹出序列
题目:输入栈的压入序列,判断另一个序列是否有可能是出栈序列。其中入栈的所有数字各不相等。
分析:实现进栈出栈操作,验证能否按所给序列将全部元素出栈。
我最开始想的方法是找规律:如果进栈顺序是 ABC,且 C 先于 AB 出栈,则出栈序列中 B 必在 A 之前。这种规律很容易理解,但实现算法比较繁琐。
bool validateStackSequences(vector<int>& pushed, vector<int>& popped) {
stack<int> st;
int index = 0;
for (int i = 0; i < pushed.size(); i++) {
st.push(pushed[i]);
while (st.empty() != true && st.top() == popped[index]) {
st.pop();
index++;
}
}
if (index != pushed.size())
return false;
return true;
}
代码:对空栈可以执行 pop 操作,不能执行 top 操作,否则会出现异常。
2.3 从上到下打印二叉树
1. 不分行打印
二叉树的层序遍历就是广度优先遍历,可以用队列实现。树是图的一种退化形式,所以广度优先遍历有向图也可以使用二叉树的层序遍历算法。
2. 分行打印
题目:二叉树的层序遍历很容易把所有结点打印在一行。请实现从上到下打印二叉树,且每层占一行。
分析:从二叉树的层序遍历可以发现:上一层第一个结点出栈时下一层的结点开始进栈,上一层最后一个结点出栈后下一层的结点全部进栈。据此,我们可以根据上一层的结点数得出下一层的结点数,而二叉树第一层结点数是 1,所以每一层的结点数都可以得出。
void layer_traverse(binaryTreeNode* tree) {
if (tree == nullptr)
return;
queue<binaryTreeNode*> qu;
qu.push(tree);
int this_layer = 1, next_layer = 0;
while (qu.empty() != true) {
tree = qu.front();
qu.pop();
cout << tree->data << " ";
this_layer--;
if (tree->lchild != nullptr) {
qu.push(tree->lchild);
next_layer++;
}
if (tree->rchild != nullptr) {
qu.push(tree->rchild);
next_layer++;
}
if (this_layer == 0) {
cout << endl;
this_layer = next_layer;
next_layer = 0;
}
}
}
3. 之字形分行打印
题目:之字形打印二叉树:第 1 行打印二叉树第 1 层从左到右的结点,第 2 行打印二叉树第 2 层从右向左的结点,以此类推打印所有层。
分析:上面用两种方法每行打印二叉树的一层。在此基础上,使用一个计数器记录遍历到哪一层。如果遍历到偶数层,不打印结点而是把结点入栈,遍历完该层后打印出栈结点。
2.4 二叉搜索树的后序遍历序列
题目:输入一个整数数组,判断该数组是不是某二叉搜索树的后序遍历结果。假设输入的数组的任意两个数字都互不相同。
图
4
图 4
图4
分析:对于二叉搜索树的每个结点,它的左子树上每个结点的值都比它小,右子树上每个结点的值都比它大。而后序遍历序列中,根结点在最后;如果有左子树,左子树序列在前部分;如果有右子树,右子树序列在左子树序列与根结点之间。所以二叉排序树的后序遍历序列中,如果存在比根结点小的部分,这部分都在左侧;如果存在比根节点大的部分,这部分都在中间。
bool varify_core(vector<int>& post, int start, int end) {
// 1.只有根结点。
if (start == end)
return true;
// 2.有右子树,可能有左子树。
if (post[end - 1] > post[end]) {
// 右子树是post[right]到post[end-1]。
int right = end - 1;
while (right - 1 >= start && post[right - 1] > post[end])
right--;
// 验证右子树。
bool check_right = varify_core(post, right, end - 1);
// 验证左子树的元素都大于根结点。
for (int i = right - 1; i >= start; i--)
if (post[i] > post[end])
return false;
// 验证左子树。
bool check_left = true;
if (right >= start)
check_left = varify_core(post, start, right - 1);
// 返回验证左右子树的结果。
return check_left && check_right;
}
// 3.没有右子树,有左子树。
else {
// 验证左子树的元素都大于根结点。
for (int i = end - 2; i >= start; i--)
if (post[i] > post[end])
return false;
// 验证左子树。
return varify_core(post, start, end - 1);
}
}
bool verifyPostorder(vector<int>& postorder) {
if (postorder.size() <= 0)
return false;
return varify_core(postorder, 0, postorder.size() - 1);
}
代码:这个代码在牛客能通过。在力扣上第一个测试 [1,2,5,10,6,9,4,3] 就通不过,报越界错误,本地可以正常运行。
2.5 二叉树中和为某一值的路径
void get_path(TreeNode* root, int target, vector<vector<int>>& paths, vector<int>& path = vector<int>{}, int sum = 0) {
if (root == nullptr)
return;
path.push_back(root->val);
sum += root->val;
if (root->left == nullptr && root->right == nullptr && sum == target)
paths.push_back(path);
get_path(root->left, target, paths, path, sum);
get_path(root->right, target, paths, path, sum);
sum -= path.back();
path.pop_back();
}
vector<vector<int>> pathSum(TreeNode* root, int target) {
if (root == nullptr)
return {};
vector<vector<int>> paths;
get_path(root, target, paths);
sort(paths.begin(), paths.end());
return paths;
}
函数 get_path 的算法来源于二叉树的(先序、中序、后序)递归遍历算法。还可以根据二叉树非递归的后序遍历算法查找路径,这里不再说明。
3. 分解让复杂问题简单化
3.1 复杂链表的复制
题目:复杂链表除了包含一个值域和一个指向下一个结点的指针域,还包含一个随机指针,这个随机指针可能指向任一个结点,也可能是空指针。请设计算法复制一个复杂链表。
1. 时间 O(n^2),空间 O(1)
分析:第一步先复制得到新链表,新链表每个结点的 random 指针暂时为空。第二步为新链表每个结点的 random 指针赋值,赋值的根据是:如果旧链表第 i 个结点的 random 指针指向旧链表的第 j 个节点,则新链表第 i 个结点的 random 指针指向新链表的第 j 个节点。
void copy_normad_list(Node* newHead, Node* head) {
while (head->next != nullptr) {
newHead->next = new Node(head->next->val);
newHead = newHead->next;
head = head->next;
}
}
Node* copyRandomList(Node* head) {
if (head == nullptr)
return nullptr;
// 1.复制得到新链表newHead,random指针全为空。
Node* newHead = new Node(head->val);
copy_normad_list(newHead, head);
// 2.给新链表的random指针赋值。
Node *old_i = head, *new_i = newHead;
Node *old_j = nullptr, *new_j = nullptr;
while (old_i != nullptr) {
old_j = head;
new_j = newHead;
while (old_j != old_i->random) {
old_j = old_j->next;
new_j = new_j->next;
}
new_i->random = new_j;
old_i = old_i->next;
new_i = new_i->next;
}
return newHead;
}
2. 时间 O(n),空间 O(n)
分析:利用哈希表。random 指针和其它链表中的指针一样,存放的是结点的地址。把旧链表每个结点的地址与新链表每个结点的地址关联起来,知道旧链表第 i 个结点的 random 指针的值为 addres_old_j,通过关联信息找到 addres_old_j 对应的地址 addres_new_j 就是新链表第 i 个结点的 random 指针的值。下面使用哈希表 unordered_map 存储这些关联信息:
图
5
图 5
图5
Node* copyRandomList(Node* head) {
if (head == nullptr)
return nullptr;
Node* newHead = new Node(head->val);
Node *p1 = head, *p2 = newHead;
unordered_map<Node*, Node*> umap;
umap.insert(make_pair(p1, p2));
while (p1->next != nullptr) {
p2->next = new Node(p1->next->val);
p1 = p1->next;
p2 = p2->next;
umap.insert(make_pair(p1, p2));
}
p1 = head;
p2 = newHead;
while (p1 != nullptr) {
p2->random = umap[p1->random];
p1 = p1->next;
p2 = p2->next;
}
return newHead;
}
3. 时间 O(n),空间 O(1)
分析:为了快速、不占额外内存地找到 random 指针的值,我们像下图 6 那样复制链表:把旧链表每个结点复制后放在它后面。比如复制结点 A 得到结点 A ′ A' A′,结点 A 指向结点 C,那么复制后的结点 A ′ A' A′ 也指向结点 C。结点 A ′ A' A′ 应该指向新结点 C ′ C' C′,而结点 C ′ C' C′ 就在结点 C 后。
图
6
图 6
图6
所以该算法分为三部:第一步像 图 6 那样复制结点。第 2 步修改序号为偶数的结点 node 的 random 指针的值:node->random = node->random->next。第 3 步把复制的链表拆分出来。
void copy_node(Node* head) {
while (head != nullptr) {
Node* node = new Node(head->val);
node->random = head->random;
node->next = head->next;
head->next = node;
head = head->next->next;
}
}
void assign_random(Node* head) {
while (head != nullptr) {
head = head->next;
if (head->random != nullptr)
head->random = head->random->next;
head = head->next;
}
}
Node* split_list(Node* head) {
Node* newHead = head->next;
Node* p = newHead;
while (true) {
head->next = p->next;
head = p->next;
if (head == nullptr)
break;
p->next = head->next;
p = head->next;
}
return newHead;
}
Node* copyRandomList(Node* head) {
if (head == nullptr)
return nullptr;
copy_node(head);
assign_random(head);
return split_list(head);
}
3.2 二叉搜索树与双向链表
题目:输入一颗二叉搜索树,将它转换成一个排序的双向链表。要求:不能创建结点,只能调整树结点的指针。
图
7
图 7
图7
为了在力扣上提交代码,按照力扣的要求让双向链表首尾相连,如第 19、23 行。
void inOrder(Node* root, vector<Node*>& res) {
if (root == nullptr)
return;
inOrder(root->left, res);
res.push_back(root);
inOrder(root->right, res);
}
Node* treeToDoublyList(Node* root) {
if (root == nullptr)
return nullptr;
vector<Node*> inOrderRes;
// 1.得到中序遍历序列。
inOrder(root, inOrderRes);
int size = inOrderRes.size();
// 2.右指针指向递增的方向。
for (int i = 0; i < size - 1; i++)
inOrderRes[i]->right = inOrderRes[i + 1];
inOrderRes[size - 1]->right = inOrderRes[0];
// 3.左指针指向递减的方向。
for (int i = size - 1; i > 0; i--)
inOrderRes[i]->left = inOrderRes[i - 1];
inOrderRes[0]->left = inOrderRes[size - 1];
return inOrderRes[0];
}
改进:没有必要使用 vector 存放指针:第一步中序遍历时改变它们的左指针,第二步改变它们的右指针:
void inOrder(Node* root, Node*& min) {
if (root == nullptr)
return;
inOrder(root->left, min);
root->left = min;
min = root;
inOrder(root->right, min);
}
Node* treeToDoublyList(Node* root) {
if (root == nullptr)
return nullptr;
// 1.中序遍历得到指向最大结点的指针。
Node* max = nullptr;
inOrder(root, max);
// 2.右指针指向递增的方向。
Node *p = max->left, *min = max;
while (p != nullptr) {
p->right = min;
p = p->left;
min = min->left;
}
// 3.让首尾相指。
min->left = max;
max->right = min;
return min;
}
使用这种方法只能让左指针指向递减的方向。因为只有中序遍历才能从二叉搜索树得到排序的序列,而我们在中序遍历时只能改变被访问结点的左指针。这是因为被访问结点的左子树已经被访问过了,而右子树还没访问,所以不能改变右指针。
使用额外空间时,左右指针可以随意指向递减或递增的方向。
3.3 序列化二叉树
3.4 字符串的排列
题目:在
n
×
n
n \times n
n×n 的国际象棋上摆放 n 个皇后,任意两个皇后不能在同一行、同一列、同一对角线上。请设计算法计算有多少摆放方法。
分析:不同行则纵坐标各不相同,不同列则横坐标各不相同,因此每列有且只有一个皇后,每行也有且只有一个皇后。假设第 i 列的皇后在第 y[i] 行放着,则 y 是 0 到 n-1 的全排列中的一个排列。
也就是说,我们先获取 0 到 n-1 的全排列,然后取出一个排列 y,那么 n 个皇后的坐标就是 (0, y[0]), (1, y[1]), …, (n-1, y[n-1])。显然这已经满足不同行且不同列,接下来只需判断任意两个皇后在不在对角线,即判断 abs(y[i] - y[j]) 是否等于 abs(i - j)。
int totalNQueens(int n) {
string s;
for (int i = 0; i < n; i++)
s.push_back('0' + i);
set<string> st = permutation(s);
int res = 0;
for (auto y : st) {
bool flag = true;
for (int i = 0; i < n; i++) // 坐标:(i, y[i])
for (int j = 0; j < n; j++) { // 坐标:(j, y[j])
if (i == j)
continue;
if (abs(y[j] - y[i]) == abs(j - i)) {
flag = false;
j = n;
i = n;
}
}
if (flag == true)
res++;
}
return res;
}