0328
剑指offer读书笔记1
2.2 编程语言
2.2.1 C++ —P24
1.一个空类的大小是多少?
对一个空的类型求sizeof(),结果是多少?
答:1字节。
空类中添加构造函数和析构函数,再求sizeof(),结果是多少?
答:依然是1字节。
那如果把析构函数标记为虚函数呢?
答:一旦一个类型中有虚函数,就会为该类型生成一个虚函数表,并在该类型的每一个实例中添加一个指向虚函数表的指针。即此时这个类型的sizeof()的结果是一个指针所占用的字节数:32位机器是4字节,64位机器是8字节。
2.拷贝构造函数的参数有什么要求?
(参考链接:C++拷贝构造函数详解)
答:如果要手动写拷贝构造函数,那它的形参中必须的一个参数是本类型的一个引用变量
,一般是常量引用
,例如Person(const Person& p)
,不能只写个Person(Person p)
,因为后面这种写法是值传递,把形参复制给实参时会调用拷贝构造函数,如果允许拷贝构造函数进行值传递,就会在拷贝构造函数内调用拷贝构造函数,就会形成永无休止的递归调用从而导致栈溢出。因此,C++的标准不允许拷贝构造函数进行值传递,所以要把参数写成常量引用。
对于一个类X, 如果一个构造函数的第一个参数是下列之一:
a) X&
b) const X&
c) volatile X&
d) const volatile X&
且没有其他参数或其他参数都有默认值,那么这个函数是拷贝构造函数.
因此:
X::X(const X&); //是拷贝构造函数
X::X(X); //不是拷贝构造函数!!!
X::X(X&, int=1); //是拷贝构造函数
X::X(X&, int a=1, int b=2); //当然也是拷贝构造函数
补充1:
:如果不专门写一个拷贝构造函数,编译器会提供一个默认的拷贝构造函数,而默认的拷贝构造函数属于浅拷贝,
- 浅拷贝不处理(拷贝)静态成员;
- 如果有成员变量存储在堆区,浅拷贝的时候只会拷贝堆区内存的地址,会导致在销毁对象时,两个对象的析构函数将对同一个内存空间释放两次,解决方法是使用深拷贝。
补充2:
拷贝构造函数调用时机:
- <1>使用一个已经创建完毕的对象来初始化一个新对象,(可以看笔记10 最下面的代码示例)
①注意:必须是一个新对象,例如:Person p2(p1);
//拷贝构造
②如果写成这样,就不算调用拷贝构造了:Person p3;
//无参构造p3 = p1;
//必须在类中重载赋值运算符=,否则编译会出错
③如果要给一个非新对象赋值,那就要在类中重载赋值运算符=
;例如:Person p4("xyz",100);
//有参构造p4 = p1;
//必须在类中重载赋值运算符=,否则编译会出错 - <2>值传递的方式给函数参数传值;
- <3>以值传递的方式返回局部对象(如果返回值是引用,即返回对象本身,就不涉及调用拷贝构造函数)。
通过对对象复制的分析,我们发现对象的复制大多在进行“值传递”时发生,这里有一个小技巧可以防止按值传递——声明一个私有拷贝构造函数,甚至不必去定义这个拷贝构造函数,这样因为拷贝构造函数是私有的,如果用户试图按值传递或函数返回该类对象,将得到一个编译错误,从而可以避免按值传递或返回对象。
private:
//拷贝构造,只是声明
CExample(const CExample& C);
补充3:
问:一个类中可以存在多于一个的拷贝构造函数吗?
答:类中可以存在超过一个拷贝构造函数。
class X {
public:
X(const X&); // const 的拷贝构造
X(X&); // 非const的拷贝构造
};
注意:如果一个类中只存在一个参数为 X& 的拷贝构造函数,那么就不能使用const X或volatile X的对象实行拷贝初始化.
class X {
public:
X();
X(X&);
};
const X cx;
X x = cx; // error
因为非const参数不能接收const参数,反过来则可以。这个可以看之前的笔记:7.3.5 指针和const (常量指针&指针常量)
如果一个类中没有定义拷贝构造函数,那么编译器会自动产生一个默认的拷贝构造函数。
这个默认的参数可能为 X::X(const X&)或 X::X(X&),由编译器根据上下文决定选择哪一个。
面试题1:赋值运算符函数 P25
在一个类中重载赋值运算符:
1.连续赋值—返回值是类对象本身,即引用,return *this;
2.形参的类型声明为常量引用,可以避免无谓消耗;
3.释放自身已有的内存空间;—我一般放在析构中,如果释放空间放在析构函数中,那就没有下面的第4点了
4.判断传入的参数和当前的实例(*this
)是不是同一个实例:如果是,就不进行赋值操作,直接返回,否则就会在释放自身内存的同时把传入的参数内存也同时释放了,这样就再也找不到需要赋值的内容了。
边界条件:连续赋值、赋值给自己
代码:
//重载赋值运算符:
// void operator=(const Person& p){//不能实现连续赋值
// this->name = p.name;
// this->age = new int(*p.age);
// }
//重载赋值运算符:(升级版)
Person& operator=(const Person& p){
this->name = p.name;
this->age = new int(*p.age);
return *this;
}
//析构:
~Person(){
cout << "析构:" << endl;
if(age != nullptr)
delete age;
age = nullptr;
}
2.2.2 C# —P28
C++中 struct 和 class 有什么区别?
问:在C++中可以用struct和class来定义类型,这两种类型有什么区别?
答:如果没有标明成员函数或者成员变量的访问权限级别,那么在struct中默认的是public
,而在class中默认的是private
。
面试题2:实现Singleton模式
设计一个类,只能生成一个该类的实例。—实现单例模式
只能生成一个实例的类,是实现了单例模式的类型。
2.3 数据结构
2.3.0 涉及到的面试题汇总
各小节 | 相关面试题 | 备注 |
---|---|---|
2.3.1 数组 可以用数组实现哈希表 |
面试题3,4,50 | |
2.3.2 字符串 | 面试题5, | |
2.3.3 链表 | 面试题6,18,22,24,25,35,36,52,62, | |
2.3.4 树 | 面试题7,8,26,32,33,34,36,40,55,68 | |
2.3.5 栈和队列 | 面试题9,30,31,32 | |
2.3.1 数组 —P37
数组、动态数组扩容、数组的地址、数组作为函数参数进行传递
1.数组
由于数组的内存是连续的,因此可以根据下标在O(1)
时间 读/写 任何元素,因此它的时间效率很高,可以利用这个优点,用数组来实现简单的哈希表:数组下标为哈希表的键值(Key
),数组的每个数字为哈希表的值(Value
),这样就构成了键值对。
2.动态数组扩容
动态数组:STL中的vector,vector每次扩容时,新的容量都是上一次的两倍。
3.数组的地址
4.8.1 指针和数组:数组的地址:对于数组int num[10];
数组名被解释为数组首元素的地址,即num
表示数组首元素num[0]
的地址,即&num[0]
;
而对数组名应用地址运算符时,得到的是整个数组的地址,即&num
表示整个数组的地址。
4.数组作为函数参数进行传递
在C++中,当数组作为函数参数进行传递时,数组就自动退化为同类型的指针。书中的示例:
面试题3:数组中重复的数字—P39
三种方法:
1.排序,然后再遍历一遍;
2.哈希表;
3.把每个num[i]放到num[num[i]]的位置去,即让数组元素值和它的下标相同。
注意:方法1和3都改动了数组元素的原有顺序,如果要求不能改动,那就用方法2:哈希表。
边界条件:空数组,即数组长度为0
面试题4:二维数组中的查找—P44
循环的退出条件是rowMin <= rowMax && colMin <= colMax
每次选取二维数组的右上角的元素,跟target比较:
如果比target大,就舍弃最右边一列;
如果比target小,就舍弃最上边一行;
如果等于target,就返回true。
边界条件:空的二维数组,即数组的行数为0
2.3.2 字符串 —P48
字符串常量 和 字符数组
为了节省内存,C++把常量字符串放到单独的一个内存区域(全局区/静态区),所以当几个指针指向同一个常量字符串时,他们实际上指向的是同一块内存地址;
但是如果用一个 字符串常量 来初始化 字符数组 str1和str2,虽然两个字符数组的内容相同,但这两个数组的地址不同,所以str1和str2不相等(str1和str2分别表示两个字符数组所占的内存块地址)。
(可参考第四章的4.2.1 和 4.2.2。 下图来自于C++笔记3:C++核心编程)
书中的示例:
结果是str1≠str2,str3等于str4。
面试题5:替换空格
书上的写法:
倒着来,双指针法,先统计空格的个数,然后resize原来字符串的长度,让p1为原始字符数组的最后一个字符的下标,p2位替换之后的字符数组的最后一个字符的下标,如果s[p1] != ' '
,就res[p2] = res[p1]; p1--; p2--;
,如果等于空格,就 p1--; res[p2] = '0'; p2--; res[p2] = '2'; p2--; res[p2] = '%'; p2--;
直到p1等于p2,就退出循环。
或者用比较笨的方法:(比较好理解)
先记录原来字符串的长度,并统计空格的个数,然后重新创建一个新的字符串,并resize新字符串的长度,然后从头遍历原始数组,遇到空格就给新字符串赋"%20",非空格就简单复制过去。
相关题目:有两个有序的数组A1和A2,数组A1后面又足够的空间容纳A2,请把A2的所有内容插入到A1中,并且依旧有序。
解题思路:如果从前往后插,会比较复杂,从后往前插,效率就比较高了。
总结:
在合并两个数组(包括字符数组)时,如果从前往后复制每个数字或字符,则需要重复移动数字或字符多次,那么就可以考虑从后往前复制,这样能减少移动的次数,从而提高效率。
2.3.3 链表
面试题6:从尾到头打印链表
方法一:
借助栈来实现,遍历链表的时候入栈st.push(head->val);
然后依次先把栈顶元素给到vector,再出栈, res.push_back(st.top()); st.pop();
方法二:
直接把链表各结点的值给到vector容器中,然后直接reverse()
一下即可。
边界条件:实参ListNode* head是空
注意:while循环的时候括号里是head,不要写head->next,否则会丢失最后一个结点的数据。
也可以统一写一个虚拟头结点preHead
,这样就不需要考虑head
为空的情况了。
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
vector<int> printListReversingly(ListNode* head) {
vector<int> res;
//stack<int> s;
ListNode* preHead = new ListNode(0);
preHead->next = head;
while(preHead->next != NULL){
//s.push(preHead->next->val);
res.push_back(preHead->next->val);
preHead = preHead->next;
}
//while(!s.empty()){
// res.push_back(s.top());
// s.pop();
//}
reverse(res.begin(), res.end());
return res;
}
};
2.3.4 树
树:
除了父节点之外,每个节点只有一个父节点,根节点没有父节点;
除了叶节点之外所有节点都有一个或多个子节点,叶结点没有子节点;
父节点和子节点之间用指针链接。
二叉树的种类:
树中每个节点最多只能有两个子节点。
- 满二叉树:一棵二叉树只有度为0的结点和度为2的结点,并且度为0的结点在同一层上;
- 完全二叉树:除了最底层节点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的节点都集中在该层最左边的若干位置;
- 二叉搜索树:左子节点总是小于或等于根节点,右子节点总是大于或等于根节点;
- 平衡二叉搜索树(AVL):它是一棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。
C++中map、set、multimap,multiset的底层实现都是平衡二叉搜索树,所以map、set的增删操作时间时间复杂度是logn,注意我这里没有说unordered_map、unordered_set,unordered_map、unordered_map底层实现是哈希表。
二叉树的遍历:(递归三要素)
二叉树主要有两种遍历方式:
- 深度优先遍历:先往深走,遇到叶子节点再往回走;
前序遍历(递归法,迭代法/循环法)
中序遍历(递归法,迭代法)—LeetCode 94题
后序遍历(递归法,迭代法) - 广度优先遍历:一层一层的去遍历
层次遍历/层序遍历(迭代法),也叫宽度优先遍历
深度优先遍历(前序/中序/后序)用递归法比较好理解,迭代法是用栈实现的(没理解);
广度优先遍历(层序遍历)一般用队列来实现。
递归三要素:
二叉树的定义:
struct TreeNode {
int val;
TreeNode *left;
TreeNode *right;
TreeNode() : val(0), left(nullptr), right(nullptr) {
}
TreeNode(int x) : val(x), left(nullptr), right(nullptr) {
}
TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {
}
};
前序遍历:LeetCode 144题
思路:
写个递归函数,参数就是树的根结点 和 用来存储结果的vector容器,
递归的停止条件是root为空;
递归的内容是把root->val给到vector容器;
然后是分别对 root->left 和 root->right 进行递归。
(递归法)
class Solution {
public:
void traversal(TreeNode* root, vector<int>& v){
if(root == nullptr) return;
v.push_back(root->val);
traversal(root->left, v);
traversal(root->right, v);
}
vector<int> preorderTraversal(TreeNode* root) {
vector<int> res;
traversal(root, res);
return res;
}
};
(迭代法)
在这里插入代码片
中序遍历:LeetCode 94题
(递归法)
class Solution {
public:
void traversal(TreeNode* root, vector<int>& v){
if(root == nullptr) return ;
traversal(root->left, v);
v.push_back(root->val);
traversal(root->right, v);
}
vector<int> inorderTraversal(TreeNode* root) {
vector<int> res;
traversal(root, res);
return res;
}
};
(迭代法)
在这里插入代码片
后序遍历:LeetCode 145题
(递归法)
class Solution {
public:
void traversal(TreeNode* root, vector<int>& v){
if(root == nullptr) return;
traversal(root->left, v);
traversal(root->right, v);
v.push_back(root->val);
}
vector<int> postorderTraversal(TreeNode* root) {
vector<int> res;
traversal(root, res);
return res;
}
};
(迭代法)
在这里插入代码片
层序遍历/层次遍历:LeetCode 102题
思路:结果放在一个二维数组中,每层的内容放到一个一维数组中,
1.先把根节点放到队列中(队列的元素师树结点,不是int),
2.然后一个while循环,
- num记录此时队列中的元素个数,
- tmp是一个临时的一维数组,存放每一层的内容;
- 用一个for循环(把当前层的元素内容放到一个临时的一维数组中,把队头的元素的两个孩子结点入队,然后将出队),
- 把tmp给到二位数组res中
- 注意:这里必须要用一个int型临时变量
size
作为for
循环的条件,不能直接写q.size()
,因为在for循环中q.size()
会发生变化,而for循环的目的本来就是要分层来读取二叉树的各个节点值,所以每次for
循环中i
的值必须要用一个int型临时变量size
来表示。
3.输出res。
代码:
class Solution {
public:
vector<vector<int>> levelOrder(TreeNode* root) {
vector<vector<int>> res;
queue<TreeNode*> q;
if(root == nullptr)
return res;
else
q.push(root);
while(!q.empty()){
int size = q.size();//当前层的元素个数
vector<int> v;//存储当前层的元素
for(int i = 0; i < size; i++){
TreeNode* node = q.front();
q.pop();
v.push_back(node->val);
if(node->left != nullptr) q.push(node->left);
if(node->right != nullptr) q.push(node->right);
}
res.push_back(v);
}
return res;
}
};
这个学会可以做以下题目:
二叉树的另外两个特例是堆和红黑树:
- 堆分为最大堆和最小堆,最大堆中根节点的值最大,最小堆中根节点的值最小。有很多需要快速找到最大值或者最小值的问题都可以用堆来解决。
- 红黑树是把树中的节点定义为红、黑两种颜色,并通过规则确保从根节点到叶节点的最长路径的长度不超过最短路径的两倍。C++的STL中,set、multiset、map、multimap都是基于红黑树实现的。
面试题7:重建二叉树
给一个二叉树的前序遍历和中序遍历的结果,请重建二叉树。
前序+中序–>可以唯一确定二叉树;(面试题7:重建二叉树)
后序+中序–>可以唯一确定二叉树;(面试题33:二叉搜索树的后序遍历序列)
前序+后序–>不可以唯一确定二叉树;
递归法:
解题思路:(AcWing上的,感觉这个好理解一些)
给两个数组,重建一棵树,那就递归这两个数组:
- 递归停止条件:
pl > pr
,返回NULL
; - 递归操作:创建
node
结点,值为前序遍历数组中下标为pl的元素,递归进入它的两个孩子; - 返回值:返回这个node结点。
先创建一个哈希表,记录中序遍历中 结点值 和 其下标 的映射关系,前序遍历数组中下标为pl的元素就是根节点,所以可以求出根结点在中序遍历数组中的下标int rootIn = hashTable[preorder[pl]];
前序遍历数组中分别是根->左子树->右子树
,中序遍历数组中分别是左子树->根->右子树
,
因此可得出下表:
前序遍历数组的左边界 | 前序遍历数组的右边界 | 中序遍历数组的左边界 | 中序遍历数组的右边界 | |
---|---|---|---|---|
左子树 | pl + 1 |
pl + rootIn - il |
il |
rootIn - 1 |
右子树 | pl + rootIn - il + 1 |
pr |
rootIn + 1 |
ir |
注意:
左子树的结点个数为rootIn - il
;
左子树在前序遍历数组的右边界值为pl + rootIn - il
,而不是pl + rootIn - il + 1
。
代码:
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode(int x) : val(x), left(NULL), right(NULL) {}
* };
*/
class Solution {
public:
//哈希表:
unordered_map<int,int> hashTable;
TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) {
if(preorder.size() == 0) return NULL;
//将中序遍历数组中元素值和下标的映射关系存储到哈希表中:
for(int i = 0; i < inorder.size(); i++){
hashTable[inorder[i]] = i;
}
TreeNode* res = recur(preorder, inorder, 0, preorder.size() - 1, 0, inorder.size() - 1);
return res;
}
//递归函数:
//参数说明: 前序遍历数组,中序遍历数组,该树在前序遍历中的下标范围(pl,pr),该树在中序遍历中的下标范围(il,ir)
TreeNode* recur(vector<int>& preorder, vector<int>& inorder, int pl, int pr, int il, int ir){
//退出递归的条件:
if(pl > pr) return NULL;
//该树的根结点是前序遍历的首元素:
TreeNode* node = new TreeNode(preorder[pl]);
//该树的根结点在中序遍历中的下标用rootIn表示:
int rootIn = hashTable[node->val];
//该树的左子树在前序遍历中的下标范围(pl + 1, pl + rootIn - il),在中序遍历中的下标范围(il, rootIn - 1):
node->left = recur(preorder, inorder, pl + 1, pl + rootIn - il, il, rootIn - 1);
//该树的右子树在前序遍历中的下标范围(pl + rootIn - il + 1, pr),在中序遍历中的下标范围(rootIn + 1, ir):
node->right = recur(preorder, inorder, pl + rootIn - il + 1, pr, rootIn + 1, ir);
//返回该树的根结点:
return node;
}
};
注意:两种思路差不多,就是递归函数的参数列表不一样,acwing上的感觉好理解一些,所以看上面的就可以。
解题思路2:(力扣上的)
代码:
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode(int x) : val(x), left(NULL), right(NULL) {}
* };
*/
class Solution {
public:
TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) {
this->preorder = preorder;
for(int i = 0; i < inorder.size(); i++) hashTable[inorder[i]] = i;
return recur(0, 0, inorder.size() - 1);
}
private:
vector<int> preorder;//前序遍历
unordered_map<int, int> hashTable;//哈希表记录中序遍历的值和索引的映射关系,Key值是inorder中的值,Value是每个值对应的索引
TreeNode* recur(int rootPre, int leftIn, int rightIn){
//rootPre表示根节点在前序遍历中的索引值
//leftIn表示中序遍历左边界---索引值
//rightIn表示中序遍历右边界---索引值
if(leftIn > rightIn) return NULL;//递归的结束条件
TreeNode* node = new TreeNode(preorder[rootPre]);//新创建一个头节点,值等于前序遍历的第一个值
int rootIn = hashTable[preorder[rootPre]];//rootIn表示根节点在中序遍历中的索引值
node->left = recur(rootPre + 1, leftIn, rootIn - 1);
node->right = recur(rootPre + rootIn - leftIn + 1, rootIn + 1, rightIn);
return node;//最终返回这个新创建的头结点
}
};
面试题8:二叉树的下一个节点
题目只给了一个TreeLinkNode* GetNext(TreeLinkNode* pNode);
刚开始一直看不懂pNode
是什么意思,后来才知道是指向二叉树某个节点的指针,上图中说这个pNode
表示一个子树的根节点,然后按照中序遍历的顺序,返回它的下一个节点的指针。另外,题目中的树节点结构体中还有一个指针next
,它指向的是某个节点的父节点的指针,题目中有说明,但是一直就没把题目看懂。
一种比较笨的方式:(就用这个方法)
先通过next指针找到这个二叉树的根节点root,然后中序遍历,将结果存到一个vector中,然后再遍历这个vector,找出pNode
指向的节点的值,最后给出它后面的一个值的指针。
(注意1:这里返回的是一个TreeLinkNode*
,所以vector中就直接存放TreeLinkNode*
,即vector<TreeLinkNode*> v;
注意2:遍历vector的时候到v.size() - 1
)
代码:
/*
struct TreeLinkNode {
int val;
struct TreeLinkNode *left;
struct TreeLinkNode *right;
struct TreeLinkNode *next;
TreeLinkNode(int x) :val(x), left(NULL), right(NULL), next(NULL) {
}
};
*/
class Solution {
public:
TreeLinkNode* GetNext(TreeLinkNode* pNode) {
TreeLinkNode* root = pNode;
while(root->next) root = root->next;
vector<TreeLinkNode*> resIn;
recur(root, resIn);
for(int i = 0; i < resIn.size() - 1; i++){
if(resIn[i] == pNode)
return resIn[i + 1];
}
return NULL;
}
void recur(TreeLinkNode* root, vector<TreeLinkNode*>& res){
if(root == NULL) return;
recur(root->left,res);
res.push_back(root);
recur(root->right,res);
}
};
第二种方法:(书上的方法)
如果一个节点有右子树,那下一个节点就是它的右子树中的最左子节点;
如果一个节点没有右子树:
且此节点是它父节点的左孩子,那下一个节点就是它的父节点;
且此节点不是它父节点的左孩子,那就用next一直向上遍历,直到找到一个节点是他父节点的左孩子,那么这个节点的父节点就是要找的下一个节点。
自己写的:
class Solution {
public:
TreeLinkNode* GetNext(TreeLinkNode* pNode) {
if(pNode == nullptr)
return pNode;
if(pNode-><