漱石斋笔谈

gaotf 的博客

#1.面试的问题

1.1 linux常用吗,知道哪些命令
ls 就是 list 的缩写,通过 ls 命令不仅可以查看 linux 文件夹包含的文件,而且可以查看文件权限、查看目录信息等等。比如ls -a
cd 切换当前目录到指定目录,比如cd ~ 进入home目录,cd /xx 进入该目录
mkdir 命令用于创建文件夹。
rm 删除一个目录中的一个或多个文件或目录,如果没有使用 -r 选项,则 rm 不会删除目录。如果使用 rm 来删除文件,通常仍可以将该文件恢复原状。
cp 命令 将源文件复制至目标文件,或将多个源文件复制至目标目录。

1.2 进程和线程的区别
(1)同一个进程的线程是共享该进程的地址空间和资源,但是不同的进程之间是独立的地址空间和资源
(2)一个进程崩溃后,在保护模式下不会对其他的进程产生影响,当时一个线程崩溃了那么该进程就崩溃了,所以说多进程比多线程要健壮
(3)进程的切换消耗的资源比较大,所以涉及到频繁的切换,使用线程更好
(4)二者均可以并发执行
1.3 进程通信方式
(1)管道(无名的)
(2)FiFO(有名的)
(3)消息队列,可以实现消息的随机查询
(4)信号量:是一个计数器,用于进程间的互斥和同步,一般结合共享内存使用
(5)共享内存:给多个进程共享一个指定的存储区,通过信号量实现互斥和同步,来达到进程间的通信
1.4 线程的通信
(1)全局变量:最好声明为volatile,在多线程过程中保证原子性
(2)消息:线程可以拥有消息队列和消息循环,在不同线程间通信
(3)事件:线程可以监视处于有信号状态的事件,适当的时候就可以对事件进行操作
1.5 TCP/IP分为几层?tcp属于哪一层?http属于哪一层?http基于tcp还是udp?
分为五层,分别是应用层,传输层,网络层,数据链路层,物理层
TCP处于传输层,UDP也在这一层,
HTTP处于应用层,HTTP/1.1 和 HTTP/2 都是基于 TCP 传输协议的,而 HTTP/3 是基于 UDP 传输协议的。(可以引申出各个版本的区别)
1.6 tcp和udp 区别
(1)tcp是面向连接的,可靠的传输,以字节流进行传输,传输效率慢,需要的资源多,应用场景为文件传输,邮件传输等,有确认,窗口,重传,拥塞控制等机制,每一条tcp连接只能是一对一的,全双工的,首部20字节开销
(2)udp是无连接的不可靠传输,不能保证数据发送顺序,以数据报文段的形式进行传输,没有拥塞控制,支持一对一,一对多,多对一和多对多,首部8个字节
1.7 三次握手、四次挥手?

1.9 Mysql的索引
索引就类似于目录,方便我们快速获取数据的一种数据结构。Mysql5.5之后的版本默认存储引擎是InnoDB,其支持B+树索引。这里就可以聊聊B+树和B树的区别,说明为什么Mysql使用B+树来作为索引。(二级索引去查主键索引:回表,以及覆盖索引。)(B+树和B树和二叉树和哈希的比较)

各种索引:
(1)主键索引:建立在主键上的索引,唯一而且不能为空值
(2)唯一索引:使用UNIQUE值创建的索引可以有多个,索引列的值必须为1,但是允许有空值
(3)普通索引:建立在普通字段上的索引,不要求字段为主键,也不需要字段为UNIQUE
(4)联合索引:将多个字段组合成一个索引,使用最左匹配原则进行查找和匹配。但是特殊情况是,如果最左匹配到的某个索引列的值是范围值的话,那么之后的索引列就不能再最左匹配。Mysql5.6以后有了索引下推优化,可以在联合索引遍历的过程重, 对联合索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数。

1.20 抢30数学题:分为说出30赢和输,不同的策略

1. 回溯问题

leetcode中我们经常会遇到很多需要求解一组集合或者所有排列的问题,这类问题的难点在于,通常情况下我们只有暴力求解这一种方式,通过遍历所有可能的解,从中找到符合我们要求的题解。常见的回溯问题包括以下类别:组合(不要求顺序,元素相同顺序不同的视为同样的答案),排列(要求顺序,相同元素不同顺序视为不同答案),切割,子集,棋盘(N皇后,数独)等。这类问题有一个共同特点就是可以抽象为树性结构,其中,所遍历的集合的大小构成了树的宽度,递归的深度构成了树的深度。下面我们给出回溯函数的模板,包括返回值、参数以及写法:

  • 回溯方法的函数伪代码

      void backtarcking(参数)
    

这里我们使用void作为回溯函数的返回值,因为我们需要做的是在集合中寻找符合题意的解,那么就可以设置一个result数组来保存符合题意的解,在递归调用的过程中,不断将解加入result即可,函数遇到终止条件就可以return

  • 回溯函数的终止条件

    终止条件视情况而定,有可能是遍历到叶子节点,有可能是需要满足一些条件,在backtracking函数中,首先要做的就是判断是否触发了终止条件,如果触发了,那么就处理当前的结果,需要的话还要return,这里的关键是,如果需要遍历所有情况,则不需要return,因为其会导致后面的通往叶子节点的路径不会被遍历到。如果需要剪枝的话,则需要return。终止条件的伪代码为:

      if(终止条件){
          处理结果;
          (return, 视情况而定)
      }
    

    还需要注意的是,有些寻找子集的问题或者排列组合的问题中,并不需要终止条件,此时只需要处理结果就可以。

  • 回溯函数的递归部分

    递归部分是回溯函数的核心,只有把递归部分写的明白,才算真正懂得了何为“回溯”,所谓回溯就是指在遍历过程中保存的中间结果,在之后的遍历时仍然需要,那么就需要保存起来,常用的方法是使用path数组保存起来,遍历到当前节点时,将节点信息保存至path中,遍历后,在将最后一个保存的信息弹出,这样path中最后保存的就是前一个节点的信息。递归部分的伪代码:

      for(int i = startIndex; i < size; i++){
          处理节点
          path.push_back(当前节点)
          backtracking(i + 1, 其他参数);
          path.pop_back(); // 回溯
      }
    

2. 回溯问题中的去重技巧

在回溯问题中有这样几道题,比如Leetcode - 40。这里的要求是解中不能包含重复的组合。我们一下就能想到,把符合题意的解放入unordered_map或者unordered_set中,每次有新的解,就去容器中find一下,如果find的结果等于容器的end(),那么就说明没有重复解,再次加入容器即可。然后理想很丰满,现实很骨感,这样的判断很容易导致超时。那么有什么更好的方法呢?既然在遍历过程中已经对所有情况进行了搜索,何不在搜索过程中就进行重复判断,比如这道题的示例1中,[1,7][7,1]都是会被搜索到的,那么怎么判断这两者重复了呢?答案就是排序,排完序后都是[1,7]。排序也分为两种情况,第一种是找到答案后再排序,这样无疑复杂度会很高,并且还是需要对结果进行判重,需要用到unordered_map或者unordered_set。第二种则是对初始数组进行排序,这样原数组就变成了[1,1,2,5,6,7,10],在搜索过程中会遇到两次[1,7],那么只需要判断:如果当前节点是否和前一个节点相同,并且在前一个节点使用过,那么当前节点就不能使用。此时可以利用一个vector<bool> used来保存每个节点的使用状态,要注意的是,判重是在同一层进行的,不同层之间used是不影响的。我们用一个例子来画一下树状图,比如nums = [1,1,2],target = 3

总体代码如下:

class Solution {
private:
    vector<vector<int>> result;
    vector<int> path;

    void backtracking(int target, const vector<int>& candidates, int startIndex, vector<bool> used){
        int sum = 0;
        for(int j = 0; j < path.size(); j++){
            sum += path[j];
        }   
        if(sum == target){
            result.push_back(path);
            return;
        }else if(sum > target)return ; // 剪枝

        for(int i = startIndex; i < candidates.size(); i++){
            if(i > 0 && candidates[i] == candidates[i - 1] && used[i - 1] == false){ // 判重
                continue;
            }
            path.push_back(candidates[i]);
            used[i] = true;
            backtracking(target, candidates, i + 1, used);
            used[i] = false; // 回溯
            path.pop_back(); // 回溯
        }
    }
public:
    vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
        result.clear();
        path.clear();
        vector<bool> used(candidates.size(), false);
        sort(candidates.begin(), candidates.end()); // 记住要排序
        backtracking(target, candidates, 0, used);
        return result;
    }
};

当然,这里还有优化的空间,比如在递归调用中,每次进入下一层之后才会计算path的和,我们可以将sum放入backtracking的参数中,在进入下一层之前计算当前sum + candidates[i]的和是否大于target,如果大于的话就break,可以减少递归调用。

3. 去重问题的特殊情况

下面我们来看一下Leetcode - 491,这道题需要找到数组中不同的递增子序列。这就说明我们不能对原数组进行排序。那么就会给判重增加一定的难度,但是想到使用mapset增加的复杂度,我们依然使用在搜索过程中进行判重的方法。在示例1的搜索过程中,会依次遍历到[4,6],[4,6,7],[4,6,7,7],[4,7],[4,7,7],[4,7],这里就产生了重复,我们画一下抽象树性图:

此时可以看到,导致重复的原因是同一层内出现了重复的元素,那么判重方法就是在同一层内判断当前节点的值是否和之前的所有值有相同情况出现。简单的增加一个判重函数即可。

//判断从[startIndex, i)内有没有和nums[i]相等的元素,有就判定为重复
    bool isRepeat(const vector<int>& nums, int start, int end){
        for(int i = start;i < end; i++){
            if(nums[i] == nums[end])return false;
        }
        return true;
    }

其中start是递归for循环中startIndex的位置,end为递归for循环中i的位置。这样判重就可以方便的降低复杂度,避免了map和set的使用。

总体代码如下:

class Solution {
    vector<vector<int>> result;
    vector<int> path;
    void backtracking(const vector<int>& nums, int startIndex){
        if(path.size() >= 2){ // path中至少存了2两个数就可以放入result中了
            result.push_back(path); // 并且由于需要遍历所以不需要return
        }
        for(int i = startIndex; i < nums.size(); i++){
            //如果 i 不等于 startIndex 并且重复,那么就跳过当前值
            if(i != startIndex && !isRepeat(nums, startIndex, i))continue;
            if(startIndex == 0 || nums[i] >= path.back()){ // 第一个数和满足条件的数要放入
                path.push_back(nums[i]);
                backtracking(nums, i + 1);
                path.pop_back();
        }
        }   
    }
    //判断从[startIndex, i)内有没有和nums[i]相等的元素,有就判定为重复
    bool isRepeat(const vector<int>& nums, int start, int end){
        for(int i = start;i < end; i++){
            if(nums[i] == nums[end])return false;
        }
        return true;
    }
public:
    vector<vector<int>> findSubsequences(vector<int>& nums) {
        result.clear();
        path.clear();
        backtracking(nums, 0);
        return result;
    }
};

1. 拷贝构造函数

如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。例如我们有如下类框架:

class HasPtr{
public:
    HasPtr(const std::string &s = std::string()):
        ps(new std::string(s)), i(0) { }
    
    //拷贝构造函数,其中动态分配了一个新的string,并将对象拷贝到ps指向的位置
    //这里涉及到string的new构造方法,即动态创建一个堆内存,该内存保存hp.ps保存的值,并将ps设置为指向当前新开辟内存,这样就不会出现当前对象的ps和传入的参数同时指向一个内存地址的情况
    HasPtr(const HasPtr& hp):
        ps(new std::string(*hp.ps)), i(hp.i) {}
private:
    std::string *ps;
    int i;
}

拷贝构造函数在很多情况下都会被隐式使用,所以其不应该是explicit的。同时,其第一个参数必须是引用类型,否则其调用永远不会成功。假设该参数不是引用,而是传值参数,

HasPtr(const HasPtr hp):   

那么拷贝构造时

HasPtr hp1;
HasPtr hp2(hp1); //拷贝构造

此时hp1是实参,将会传值给形参hp,而此时相当于调用HasPtr hp(hp1),此时编译器又会调用类的拷贝构造函数,导致这一过程无限继续下去,使得调用无法成功。

拷贝构造会在以下情况发生:

  • 拷贝初始化(用=初始化变量)
  • 将一个对象作为实参传递给一个非引用类型的形参
  • 从一个返回类型为非引用类型的函数返回一个对象
  • 用花括号列表初始化一个数组中的元素或一个聚合类中的成员
  • 某些类类型还会对它们所分配的对象使用拷贝初始化,例如初始化标准库容器或者调用其insertpush成员时。(与之相对的是,用emplace创建的元素都进行直接初始化)。

2. 拷贝赋值运算符

与类控制其对象如何初始化一样,类也可以控制其对象如何赋值。

HasPtr hp1, hp2;
hp1 = hp2; //使用拷贝赋值运算符

为了实现自定义的拷贝赋值运算符,我们需要重载赋值运算符,其本质上就是一个函数,接受一个与其所在类相同类型的参数,如下形式:

class HasPtr {
public:
    HasPtr(const std::string &s = std::string()) : ps(new std::string(s)), i(0) { }
    HasPtr(const HasPtr &hp) : ps(new std::string(*hp.ps)), i(hp.i) { }

    //重载赋值运算符实现拷贝赋值运算符,返回引用
    HasPtr& operator=(const HasPtr &rhs_hp) {
        if(this != &rhs_hp){
            //在堆上新开辟内存,保存hp.ps的内容,并返回一个指向当前位置的指针
            std::string *temp_ps = new std::string(*rhs_hp.ps);
            delete ps; //这里销毁了ps指向的内存,而ps指针还存在
            ps = temp_ps; //将当前对象的ps设置为temp_ps
            i = rhs_hp.i;
        }
        return *this; //返回自身类类型的引用
    }
private:
    std::string *ps;
    int i;
};

为了与内置类型的赋值保持一致,赋值运算符通常返回一个指向其左侧运算对象的引用。

3. 合成拷贝构造函数

如果我们没有为一个类定义拷贝构造函数,编译器就会为我们定义一个,称为合成拷贝构造函数。一般情况下,它的作用是将其参数给定的对象中每个非static成员逐个拷贝到正在创建的对象中。那么我们就可以理解直接初始化和拷贝初始化的差异了。(拷贝初始化是指使用=进行的初始化)。

string dots(10, '.');  //直接初始化
string s(dots);        //直接初始化
string s2 = dots;      //拷贝初始化
string null_book = "9-99-999-99"; //拷贝初始化
string nines = string(100, '9');  //拷贝初始化

使用直接初始化时,实际上是要求编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数。当使用拷贝初始化时,要求编译器将右侧运算对象拷贝到正在创建的对象中,必要的化还要进行类型转换。拷贝初始化一般情况下使用拷贝构造函数来完成,在之后的章节会介绍移动构造函数,会更方便的进行拷贝构造。

4. 合成拷贝赋值运算符

如果我们没有为一个类定义拷贝赋值运算符,编译器就会为我们定义一个,称为合成拷贝赋值运算符。它会将右侧对象的每个非static成员赋予左侧运算对象的对应成员,对于数组成员,逐个赋值数组元素。最终返回一个指向左侧运算对象的引用。

5. 注意事项

  • 某些情况下,合成拷贝构造函数和合成拷贝赋值运算符的作用是禁止该类型对象的构造和赋值。这时候是因为我们定义的类并不需要拷贝、赋值或者销毁其成员。这里参考13.1.6章节的阻止拷贝章节。
  • 对于使用shared_ptr控制的对象类型,拷贝构造和拷贝赋值会增加count次数,而weak_ptr则不会。

1.什么是背包

背包问题是动态规划部分的经典题型。问题可以描述为:给定一组物品,每种物品都有自己的重量和价格,在限定的总重量内,我们如何选择,才能使得物品的总价格最高。 一般来讲,背包问题有以下几种分类:

1. 01背包问题
2. 完全背包问题
3. 多重背包问题

在leetcode刷题过程中,我们需要掌握这几种背包问题的写法。要注意的是,有些题目并不会直接告诉你这是一道背包问题,而是需要我们利用发散思维将问题转换为背包问题,以此来找到解题方法。

01背包问题

1.题目

最基本的背包问题就是01背包问题(01 knapsack problem):一共有N件物品,第ii从1开始)件物品的重量为w[i],价值为v[i]。在总重量不超过背包承载上限W的情况下,能够装入背包的最大价值是多少?

我们的目标是书包内物品的总价值,而变量是物品和书包的限重,所以我们可定义状态dp,首先使用二维数组来表示dp过程:

dp[i][j]表示将前i件物品装进最大容量为j的背包所获得的最大价值,0 <= i <= N, 0 <= j <= W

此时根据dp表示的含义,自然而然dp[0][j]为0,因为此时并未装进物品。此时当i > 0时,有两种情况:

  1. 不装第i件物品,即dp[i][j] = dp[i - 1][j]
  2. 装入第i件物品(前提是剩余容量能装下),即dp[i][j] = dp[i - 1][j - w[i]] + v[i]

那么由此得到状态转移方程:

dp[i][j] = max(dp[i − 1][j], dp[i − 1][j − w[i]] + v[i]) // j >= w[i]

完整代码如下:

for(int i = 1; i < w.size(); i++) { // 遍历物品
    for(int j = 0; j <= bagweight; j++) { // 遍历背包容量
        if (j < w[i]) dp[i][j] = dp[i - 1][j]; 
        else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]);
    }
}

此时先遍历的是物品,再遍历背包容量,反之也是可以的,这里注意到j的遍历是正向的,因为下一层使用的是上一层的状态,具体而言,需要的是左上角的信息。

上述状态转移方程可知,dp[i][j]的值只与dp[i - 1][j]有关,所以我们使用动态规划常用的方法(滚动数组)来对空间复杂度进行优化,需要满足的条件是上一层可以重复利用,直接拷贝到当前层。此时dp[j]表示容量为j的背包,所装的物品最大价值。同理可得到状态转移方程:

dp[j] = max(dp[j], dp[j - w[i]] + v[i])

初始化时,将dp[i]全部初始化为0.完整代码如下:

for(int i = 0; i < weight.size(); i++) { // 遍历物品
    for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
    }
}

这里我们发现,j的遍历顺序是逆序的。原因是:如果是正向计算,那么会出现状态重合的情况,例如下面的例子:背包容量为4,物品属性如下:

物品重量 物品价值
1 15
3 20
4 30

此时dp[1] = 15, dp[2] = dp[2 - w[0]] + v[0] = d[1] + 15 = 30。可以注意到这里dp[2]恰巧用到了dp[1]的数据,也就是说此时第一个物品被装了两次。那么为了避免这种情况,我们就需要对j进行逆序遍历,这样用到前面的数据时,前面的数据还没有被遍历到,其状态是0,模拟的就是一件物品只能被装进去一次。同时,ij的遍历顺序也不能交换,如果j放在外层,那么遍历的结果是背包只放了一件物品。

完整的代码如下:

void test_1_wei_bag_problem() {
    vector<int> weight = {1, 3, 4};
    vector<int> value = {15, 20, 30};
    int bagWeight = 4;

    // 初始化
    vector<int> dp(bagWeight + 1, 0);
    for(int i = 0; i < weight.size(); i++) { // 遍历物品
        for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
            dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
        }
    }
    cout << dp[bagWeight] << endl;
}

int main() {
    test_1_wei_bag_problem();
}

学习完以上的知识,就可以试着做一做leetcode的416和1049题,这两道题是经典的可以转化为背包问题的题目。下一篇博客将总结多重背包问题。

1. 何为前后指针

在二叉树的一些题目中,需要我们记录前后两个相邻节点各自的信息(尤其是在二叉搜索树中经常出现,比如501和530,那么常用的技巧就是在遍历过程中设置两个指针,分别指向这两个相邻节点,在处理之后,将前一个指针指向当前节点,当前指针指向下一个节点。

2. 递归中构造前后指针

class Solution{
    private:
        TreeNode* pre;
        void traversal(TreeNode* cur){
            if(cur == NULL)return;
            traversal(cur->left); //左
            if(pre != NULL){ //中
                //处理cur和pre
            }
            pre = cur; //将前一个指针指向当前节点
            traversal(cur->right); //右
        }
    public:
        TreeNode* getMinimumDifference(TreeNode* root){
            traversal(root);
            return root;
        }
}

这里使用的是前序遍历,实际中根据需要自行修改即可。

3. 迭代中构造前后指针

class Solution{
    public:
        TreeNode* getMinimumDifference(TreeNode* root){
            stack<TreeNode*> stk;
            if(root == NULL)return root;
            TreeNode* pre = NULL;
            TreeNode* cur = root;
            while(!stk.empty() || cur != NULL){
                if(cur != NULL){
                    stk.push(cur);
                    cur = cur->left; //左
                }else{
                    cur = stk.top();
                    stk.pop();
                    if(pre == NULL){
                        //此时cur指向初始节点(最左下或者最右下)
                    }
                    if(pre != NULL){
                        //处理pre和cur
                    }
                    pre = cur; //前一个指针指向当前节点
                    cur = cur->right; //这里和上面对应,上面始终遍历左侧,则这里等于右子节点
                }
            }
            return root;
        }
}

相比较递归中的前后指针构造,我个人更熟悉迭代法中构造前后指针。在二叉搜索树中这样的处理方式很常见,需要慢慢体会这样的写法。

1.this指针

当我们调用成员函数时,实际上是在替某个对象调用它。成员函数通过一个名为this指针的隐式参数来访问它调用的对象的地址,这样在成员函数内部,我们就可以使用this来访问类内成员。那么由于this始终指向这个地址,所以它是一个常量指针,不允许改变它所保存的对象地址。但是this对应的是指向类类型的非常量版本的常量指针,我们知道,这样的指针是不能绑定到一个常量对象上的。具体看下面的例子:

class people{
    public:
        people(){}
        people(int x):score(x){}
        ~people(){}
        int getScore(){return score;}
    private:
        int score;
};

int main(){
    const people p1(22);//常量
    cout << p1.getScore() << endl; //error,不能用常量绑定非常量指针
    return 0;
}

这段代码在编译器中是无法编译通过的。我们知道用指向常量的指针来绑定常量和非常量都是可以的,那么为了提高成员函数的灵活性,我们就可以将这个this指针定义为指向常量的指针(同时其本身就是常量指针)。方法就是在成员函数的参数列表后面加入关键字const,则上述getScore函数修改为:

int getScore() const {return score;}

这样上述代码的main函数就可以正常运行了。

2.补充

对于成员函数,其有一个隐式参数,来表示对象的地址,例如:

class Foo{
    public:
        void hello(int a){
            cout << "hello" + a << endl;
        }
        //等价于
        //void hello(Foo* const this, int a){
            //out << "hello" + a << endl;
        //}
};

int main(){
    Foo foo;
    int a = 2;
    foo.hello(a);
    //等价于
    foo.hello(&foo, a);
}

上面的代码展示了对象地址的隐式参数传递,当我们将hello设置为常量函数后,就可以理解为其等价版本变为:

void hello(const Foo* const this, int a){
    out << "hello" + a << endl;
}

3.常量成员函数的重载

两个成员函数,名字和参数列表都一样,但是一个是常量函数一个不是,则也算重载。

class people{
    public:
        people(){}
        people(int x):score(x){}
        ~people(){}
        int getScore() {return score;}
        int getScore() const {return score};
    private:
        int score;
};

int main(){
    const people p1(22);//常量对象
    people p2(23); //非常量对象
    cout << p1.getScore() << p2.getScore() << endl; //重载
    return 0;
}

二叉树的种类

满二叉树

满二叉树的定义是:如果一颗二叉树只有度为0的节点和度为2的节点,且度为0的节点在同一层上,则该二叉树是满二叉树,如下图:

满二叉树的深度为k,则节点共有 $ 2^{k} - 1 $个(这里使用等比数列相加即可得出)。

完全二叉树

在完全二叉树中,除了最底层节点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的节点都集中在该层最左边的若干位置。

优先级队列其实是一个堆,堆就是一个完全二叉树,而且保证了父子节点的顺序关系。

二叉搜索树

二叉搜索树是节点带有数值的一棵树,并且要满足以下关系:
若它的左子树不为空,则左子树上所有节点的值都小于根节点的值;
若它的右子树不为空,则右子树上所有节点的值都大于根节点的值;
它的左、右子树也分别为二叉排序树。

平衡二叉搜索树

平衡二叉搜索树又被称为AVL(Adelson-Velsky and Landis),且具有如下性质:它是一棵空树或者它的左右两个子树的高度差的绝对值不超过1,并且左右两颗子树都是平衡二叉树。

最后一棵树不是平衡二叉搜索树,因为根节点的左右子树的高度差的绝对值超过了1(此时左边为6,右边无子树为0)。

C++中map、set、multimap,multiset的底层实现都是平衡二叉搜索树,所以map、set的增删操作时间时间复杂度是logn,注意我这里没有说unordered_map、unordered_set,这两者的底层实现是哈希表。

二叉树的定义

在面试的时候,因为大部分情况是没有IDE和代码提示的,需要自己定义二叉树,下面是常用的定义二叉树的方法:

struct TreeNode{
    int val;
    TreeNode* left;
    TreeNode* right;
    TreeNode(): val(0), left(NULL), right(NULL) {}
    TreeNode(int x): val(x), left(NULL), right(NULL) {}
}

二叉树的遍历方式

说到二叉树,那么常见的考察方式就是其遍历方式,大体上可以概括为两种:
1. 深度优先遍历
$\qquad\bullet$前序遍历(递归,迭代)
$\qquad\bullet$中序遍历(递归,迭代)
$\qquad\bullet$后序遍历(递归,迭代)
2. 广度优先遍历
$\qquad\bullet$层次遍历(迭代)

在深度优先遍历中,前、中、后指的是根节点所在的位置。

深度优先遍历(迭代法)

下面介绍一下使用递归方法进行深度优先遍历,这里假设我们使用vector来保存遍历过程中的节点值。这里提供的是递归方法的思路,之后只需要按照这个思路稍微修改代码满足需要即可。

1. 前序遍历

//中-左-右
class Solution{
public:
    void traversal(TreeNode* cur, vector<int>& vec){
        if(cur == NULL)return;
        vec.push_back(cr->val); //中
        traversal(cur->left); //左
        traversal(cur->right); //右
    }

    vector<int> preorderTraversal(TreeNode* root){
        vector<int> result;
        if(root == NULL)return result;
        traversal(root, result);
        return result;
    }
}

同理,中序遍历和后序遍历只需要修改递归代码中的顺序即可,思路都是一样的。

2. 中序遍历

//左-中-右
class Solution{
public:
    void traversal(TreeNode* cur, vector<int>& vec){
        if(cur == NULL)return;
        traversal(cur->left); //左
        vec.push_back(cr->val); //中
        traversal(cur->right); //右
    }

    vector<int> preorderTraversal(TreeNode* root){
        vector<int> result;
        if(root == NULL)return result;
        traversal(root, result);
        return result;
    }
}

3. 后序遍历

//左-右-中
class Solution{
public:
    void traversal(TreeNode* cur, vector<int>& vec){
        if(cur == NULL)return;
        traversal(cur->left); //左
        traversal(cur->right); //右
        vec.push_back(cr->val); //中
    }

    vector<int> preorderTraversal(TreeNode* root){
        vector<int> result;
        if(root == NULL)return result;
        traversal(root, result);
        return result;
    }
}

下一篇博客将总结使用迭代方法实现深度优先遍历和广度优先遍历(即层次遍历)。

1. 层次遍历

层次遍历是广度优先遍历,即先遍历二叉树的每一层节点,再向下继续遍历每一个节点的子节点。基本思想是,当同层节点都遍历后,此时同层节点的子节点进行下一轮遍历,不难想到,我们应该使用队列来实现这一想法。因为队列先进先出的特性,使得同层的节点必定相邻,我们只需要将同层每个节点放入队列后,在它们按序出列时放入子节点即可。
下面是层序遍历的代码模板,事实上,这份模板可以解决很多类似于层序遍历的问题,只需要修改很少的部分即可:

class Sulution{
    public:
        vector<vector<int>> levelOrder(TreeNode* root){
            queue<TreeNode*> que;
            vector<vector<int>> result;
            if(root == NULL)return result;
            que.push(root);
            while(!que.empty()){
                int size = que.size();
                vector<int> vec;
                for(int i = 0; i < size; i++){
                    TreeNode* cur = que.front();
                    que.pop();
                    vec.push_back(cur->val);
                    if(cur->left)que.push(cur->left);
                    if(cur->right)que.push(cur->right);
                }
                result.push_back(vec);
            }
            return result;
        }
}

这样每一层的节点都保存至一个vector内,每一层迭代结束后,将相应的vector保存到result中。

2. 前序遍历

在上一期实现递归深度优先遍历时,其实关键就是将每一次递归调用的函数局部变量、参数值和返回地址压入调用栈中,然后调用返回后,从栈顶弹出上一次递归的各项参数,这就是递归能返回最终结果的原理。那么我们当然可以手动使用stack来实现模拟这一过程。

栈(stack)空间先入后出,对于前序遍历中左右的结果,首先将根节点放入栈中,然后弹出栈顶元素,再放入右孩子,最后放入左孩子,这样出栈的顺序才正确。代码如下:

class Solution{
    public:
        vector<int> preOrder(TreeNode* root){
            stack<TreeNode*> stk;
            vector<int> result;
            if(root == NULL)return result;
            stk.push(root);
            while(!stk.empty()){
                TreeNode* cur = stk.top();
                stk.pop();
                result.push_back(cur->val);
                if(cur->right)stk.push(cur->right);
                if(cur->left)stk.push(cur->left);
            }
            return result;
        }
}

3. 后序遍历

前序遍历是中左右的顺序,后序遍历是左右中的顺序。而恰恰我们可以调整前序遍历放入子节点的左右顺序来产生中右左的序列(此时先放入左子节点,后放入右子节点),然后将这个序列逆序即可得到左右中的顺序。代码只需要在前序的基础上修改少部分即可:

class Solution{
    public:
        vector<int> preOrder(TreeNode* root){
            stack<TreeNode*> stk;
            vector<int> result;
            if(root == NULL)return result;
            stk.push(root);
            while(!stk.empty()){
                TreeNode* cur = stk.top();//中
                stk.pop();
                result.push_back(cur->val);
                if(cur->left)stk.push(cur->left);//先放入左
                if(cur->right)stk.push(cur->right);//后放入右
            }
            reverse(result.begin(), result.end());//逆序
            return result;
        }
}

4. 中序遍历

中序遍历的顺序是左中右,此时如果我们还按照上述的方法进行中序遍历就会发现,当访问到左节点时,紧跟的“中”是其父节点,这就造成了不便性。这里我们使用一个指针来控制访问元素,栈则用来处理节点上的元素。

class Solution {
public:
    vector<int> inorderTraversal(TreeNode* root) {
        vector<int> result;
        stack<TreeNode*> stk;
        TreeNode* cur = root;
        while(cur != NULL || !stk.empty()){
            if(cur != NULL){//指针访问节点,访问到最左下的起始元素
                stk.push(cur);
                cur = cur->left;//左
            }else{//当前节点为空时,将栈顶元素拿出来
                cur = stk.top();
                stk.pop();
                result.push_back(cur->val);//中节点
                cur = cur->right;//访问右子节点
            }
        }
        return result;
    }
};

中序遍历需要仔细思考一下其原理,最好是在纸上测试一遍,这里蕴含的其实是“回溯”的知识,在之后的题解中,也会涉及到二叉树+回溯的题目,即在当前元素遍历后,如何再回到父节点再向另一个方向遍历。需要仔细体会。

众所周知,hexo插入图像有两种方式,一种是使用 markdown 语法:

![图片替代文本](图片链接 "图片title")

还有一种是使用 HTML 语法来更精确的控制插入的图像:

<div align = "center">
    <img src = ./Fig/Fig27.png width = 90%> //这里使用相对路径来引用图像
</div> 

后者能更精确的控制图像的大小和位置,来满足插入图像时的各种需求。

我使用的markdown编辑器是vscode,一方面vscode的启动速度快,占用资源少,另一方面,vscode众多的强大插件能完美的满足我在编辑markdown时的要求。这里如果你使用markdown文件最终是为了生成pdf文件的话,强烈推荐一款vscode下的插件:

每次打开一个.md文件后,在编辑区右键即可打开预览模式,

并且上述两种插入图像的方式在这个插件下都可以正确显示。但是每次将.md文件推送到网站上时,插入的图像都不会正确显示,查找一番后,我发现了在Hexo官方网站上有这个问题的解决方案。这里贴一下链接:Hexo官方中文网站

具体方法是:

  1. 在根目录下配置文件 _config.yml 中有 post_asset_folder:false 改为true。这样在建立文件时,Hexo会自动建立一个与文章同名的文件夹,这样就可以把与该文章相关的所有资源(图片)都放到那个文件夹里方便后面引用。
  2. git bash安装插件:npm install https://github.com/7ym0n/hexo-asset-image –save(这是个修改过的插件,经测试无问题),使用这个插件来引入图片(这里如果遇到了安装时没有写入权限的问题,则在命令最后加入 -g 来获取权限。
  3. 插入图片时用这种方式:**,其中test.jpg就是你要引用的图片,后面的This is an test image是图片描述,可以自己修改。注意这里的图片描述不能删除**,因为这个插件将其定义为不可省略。

上面这个方法的缺点是在vscode预览中并不能看到插入的图像,这里可以使用hexo发布至本地来查看是否成功。输入如下命令:

hexo s

然后在浏览器地址栏访问如下网页查看效果:

http://localhost:4000/

1. 函数中几种const构不构成重载的分析

  • 函数中值传递不构成重载,因为传入的只是一个值,而且会报重定义错误

      void test(int a){
          cout << a << endl;
      }
      //下面的代码会报重定义错误
      void test(const int a){
          cout << a << 2endl; 
      }
    
  • 函数中引用传递和指针传递增加const构成重载,因为传入的是确切的对象,const可以区分传入的这些对象能否被修改的性质

      int test(int *a){
          return *a;
      }
      int test(const int *a){
          return *a;
      }
    
  • 成员函数中函数后面加const构成重载,和上面的情况同理,是能决定成员变量能否被修改的

      class test{
      public:
          test(){}
          ~test(){}
          int display(){
              return this.a;
          }
          int display const(){
              return this.a;
          }
      private:
          int a;
      }
    

2.如何做到在const成员函数内修改成员变量

  • 使用mutable关键字去修饰成员变量,这样成员变量始终是可以变化的

  • 定义一个常量指针指向this所指的对象,然后用这个指针去修改成员变量

      class test{
      public:
          test(){}
          ~test(){}
    
          void display(int a) const{
              test *const te = const_cast<test *const>(this);
              te->value = 2;
              cout << te-value << endl;
          }
      private:
          int value;
      }
    

3.内联函数和宏定义的区别

1.内联函数是函数,有参数类型检查,更为安全
2.内联函数由编译器进行处理,而宏定义由预处理器进行处理
3.内联函数处理时被插入到对应代码区域,而宏定义只是简单的文本替换

4.c++编译过程

分为预处理+编译+汇编+链接

  • 预处理:主要处理那些源代码中以#开始的预编译指令,主要处理规则如下:
    • 将宏定义进行文本替换
    • #include的文件插入到对应位置(递归进行,因为可能循环包含)
    • 删除注释
  • 编译:进行一系列词法分析,语法分析,语义分析及优化后生成相应的汇编代码文件(内联函数就在这一阶段)
  • 汇编:将汇编代码转变成机器语言
  • 链接:将各个目标文件组装在一起,并生成可执行文件。

5.程序的内存分配

1.栈区:由编译器自动分配释放,存放函数的参数值,局部变量等。
2.堆区:由程序员自己分配释放,分配方式类似于链表
3.全局静态区:全局变量和静态变量的位置,初始化的在一起,未初始化的在一起
4.常量区:常量存放地址,程序接受系统释放
5.程序代码区:存放函数的二进制代码

6.内存中堆和栈的区别

1.栈由系统统一分配,例如局部变量声明后就自动放在栈中(先进后出)
2.堆需要自己申请,比如malloc函数和new

7.实现一个在main函数运行之前运行的函数

1.使用attribute关键字

__attribute((constructor))void before(){
    cout << "before main" << endl;
}

2.使用全局对象的构造函数,其会在main函数执行之前运行

class my_test{
public:
    my_test(){
        cout << "test" << endl;
    }
    ~my_test(){}
}

my_test test;

void main(){
    cout << "this is main func" << endl;
}

8.c++常量储存的位置

1.局部常量(和局部变量位置一样):栈区
2.全局常量(和全局变量位置一样):全局静态区
3.字面值常量:存放在常量区

9.C++隐式转换

1.算数类型

  • 整型提升:将小整数转换成较大的整数类型
  • 有符号类型转换为无符号类型,其值可能回发生变化
  • 在条件判断中,非布尔类型自动转换为布尔类型(0为false,其余为true)

2.类类型
《C++ Primer》中提到:可以用单个形参来调用的构造函数定义了从形参类型到该类类型的一个隐式转换。

class Test{
public:
    Test(int i);
};

Test t1 = 1;

这里由于隐式转换,1先被Test构造函数构造成Test对象,然后才被赋值为1,其操作等同于

Test tmp(1);
Test t1 = tmp;

为了防止这种看起来很模棱两可的写法,可以使用关键字 explicit来防止隐式转换。其只对有一个参数的类构造函数有效,如果类构造函数的参数大于等于2是不会产生隐式转换的(有一种特殊情况,就是多个参数,但是除了其中某一个之外,其他参数都有默认值)。

10. extern “C”作用

被该语句修饰的变量和函数是按照C语言方式编译和链接的。一般在C++代码调用C语言代码,在C++的头文件中使用。实现C++和C的混合编程。

作为一种面向对象的语言, C++ 支持函数重载,而过程式语言 C 则不支持。所以,函数被 C++ 编译后在符号库中的名字与 C 语言的有所不同。例如,假设某个函数的原型为:

void foo( int x, int y );

该函数被 C 编译器编译后在符号库中的名字为 _foo ,而 C++ 编译器则会产生像 _foo_int_int 之类的名字(不同的编译器可能生成的名字不同,但是都采用了相同的机制,生成的新名字称为 mangled name )。 _foo_int_int 这样的名字包含了函数名、函数参数数量及类型信息, C++ 就是靠这种机制来实现函数重载的。例如,在 C++ 中,函数 void foo( int x, int y ) 与 void foo( int x, float y ) 编译生成的符号是不相同的,后者为 _foo_int_float 。

假设在C++中,模块A的头文件如下:

//模块A头文件 moduleA.h
#ifndef MODULE_A_H
#define MODULE_A_H
int foo( int x, int y );
#endif

在模块 B 中引用该函数:

// 模块B实现文件 moduleB.cpp
#include "moduleA.h"
foo(2,3);

实际上,在连接阶段,连接器会从模块 A 生成的目标文件 moduleA.obj 中寻找 _foo_int_int 这样的符号!

对于上面例子,如果 B 模块是 C 程序,而A模块是 C++ 库头文件的话,会导致链接错误;同理,如果B模块是 C++ 程序,而A模块是C库的头文件也会导致错误。而加了extern “C”后,链接的时候寻找的就是未经修改的符号名_foo,不会产生错误。

11.说说RTTI

1.RTTI是运行阶段类型识别的简称,其用途为:假设有一个基类和多个派生类,则可以让基类指针指向其中任何一个类的对象,但是我们想要知道指针具体指向的是哪个类的对象,因为:

  • 有可能想要调用类方法的正确版本,因为派生类对象可能包含不是继承来的方法,即只有派生类对象可以调用该方法。
  • 也有可能是出于调试的目的,想跟踪生成对象的类型。
    2.有3个支持RTTI的元素:
  • dynamic_cast运算符使用一个指向基类的指针来生成一个指向派生类的指针,其主要用于“安全的向下转型”,即基类对象的指针或者引用来转换为同一继承层次的其他指针和引用。向下转型有两种情况,一种是基类指针指向派生类对象,这种转换是安全的;另一种是基类指针所指对象为基类对象,这种情况会失败并返回空指针(即0)。对于引用类型,失败的时候抛出一个bad_cast异常。
  • typeid运算符和type_info类:用于判断具体的类型
  • 二者之间的区别:前者用于判断是否能够进行转换,并进行类型检查,而后者直接判断具体的类型。

new和malloc的区别

1.new分配在自由储存区(C++中一个抽象概念,凡是通过new进行内存申请的,该内存就是自由储存区);malloc从堆上动态分配内存。那么自由存储区是否能够是堆(问题等价于new是否能在堆上动态分配内存),这取决于operator new 的实现细节。自由存储区不仅可以是堆,还可以是静态存储区,这都看operator new在哪里为对象分配内存。
2.new操作符内存分配成功时,返回的是对象类型的指针,类型严格与对象匹配,无须进行类型转换,故new是符合类型安全性的操作符。而malloc内存分配成功则是返回void * ,需要通过强制类型转换将void*指针转换成我们需要的类型。
3.new内存分配失败时,会抛出bac_alloc异常,它不会返回NULL;malloc分配内存失败时返回NULL。
4.使用new操作符申请内存分配时无须指定内存块的大小,编译器会根据类型信息自行计算,而malloc则需要显式地指出所需内存的尺寸。
5.使用new操作符来分配对象内存时会经历三个步骤:

  • 第一步:调用operator new 函数分配一块足够大的,原始的,未命名的内存空间以便存储特定类型的对象。

  • 第二步:调用相应的构造函数以初始化对象,并为其传入初值。

  • 第三部:对象构造完成后,返回一个指向该对象的指针。
    使用delete操作符来释放对象内存时会经历两个步骤:

  • 第一步:调用对象的析构函数。

  • 第二步:编译器调用operator delete(或operator delete[])函数释放内存空间。
    而malloc/free是库函数,只能动态的申请和释放内存,无法强制要求其做自定义类型对象构造和析构函数
    6.对数组的处理:new对数组的支持体现在它会分别调用构造函数函数初始化每一个数组元素,释放对象时为每个对象调用析构函数。注意delete[] 要与new []配套使用,不然会找出数组对象部分释放的现象,造成内存泄漏。而malloc不会进行这些操作。
    7.operator new的实现可以基于malloc,而malloc的实现不可以去调用new,示例代码如下:

    void * operator new (sieze_t size)
    {
    if(void * mem = malloc(size)
    return mem;
    else
    throw bad_alloc();
    }

8.重新分配内存
使用malloc分配的内存后,如果在使用过程中发现内存不足,可以使用realloc函数进行内存重新分配实现内存的扩充。realloc先判断当前的指针所指内存是否有足够的连续空间,如果有,原地扩大可分配的内存地址,并且返回原来的地址指针;如果空间不够,先按照新指定的大小分配空间,将原有数据从头到尾拷贝到新分配的内存区域,而后释放原来的内存区域。
new没有这样直观的配套设施来扩充内存。

sizeof的一些特性

  • sizeof是运算符,不是函数
  • sizeof不能求得void类型的长度
  • sizeof可以求得void指针的长度,在32位的机器上为4byte字节(因为是指针变量)
  • sizeof可以求得静态分配内存的数组的长度,需要注意的是,如果在一个函数内部使用sizeof对数组求长度,而且这个数组是通过函数参数的形式传递进来,则长度还是4字节,因为这里参数被自动转换为了指针(即数组第一个元素的地址),这样可以减少拷贝导致的效率低下。另外,当字符数组表示字符串时,sizeof值将会把’\0’包含进去。
  • sizeof不能求得动态分配的内存的大小,比如使用new分配的数组,还是会被当做指针求长度
  • 当表达式作为sizeof的操作数时,它返回表达式的计算结果的类型大小,但是不会对表达式求值。
  • sizeof可以对函数调用求大小,并且求得的大小等于返回类型的大小,但是不执行函数体
  • sizeof求得的结构体(及其对象)的大小并不等于各个数据成员对象的大小之和。这里涉及到内存对齐的规则,具体如下:
    结构体的大小等于结构体内最大成员大小的整数倍;结构体内的成员的首地址相对于结构体首地址的偏移量是其类型大小的整数倍。

内存对齐

尽管内存是以字节为单位,但是大部分处理器并不是按字节块来存取内存的.它一般会以双字节,四字节,8字节,16字节甚至32字节为单位来存取内存,我们将上述这些存取单位称为内存存取粒度。提高存取数据的速度。比如有的平台每次都是从偶地址处读取数据,对于一个int型的变量,若从偶地址单元处存放,则只需一个读取周期即可读取该变量;但是若从奇地址单元处存放,则需要2个读取周期读取该变量。某些平台只能在特定的地址处访问特定类型的数据,否则抛出硬件异常给操作系统。32位机器和64位的机器内存对齐中,内置类型只有指针不一样:32为4字节,64位为8字节。

函数调用压栈过程

1.首先main函数中参数从右到左进行压栈,然后压入main函数的返回地址,如果发生函数调用,同样会压入该函数的地址,然后跳转到该函数内进行参数压栈等操作,函数返回时,参数出栈,最后会通过栈中保存的函数返回地址返回到main函数内,实现回溯过程。

多线程问题

1.join函数:让一个线程阻塞在当前位置,如下:

void fun(){
    cout << "fun() << endl;
}

int main(){
    thread t1(fun);
    t1.join();
    cout << "main() << endl;
}

此程序的打印顺序为:fun() -> main(),因为t1.join()函数会自动的将主线程阻塞,直到t1线程完成,才会继续执行主线程。同时join函数还有另外一个作用,就是回收该线程的资源,如果删除掉t1.join(),最后程序会报错。

2.detach()函数
detach()函数的作用是将子线程和主线程分离,让子线程在后台独立运行,如果主线程运行的很快,子线程还未执行完的话,那么子线程会在后台继续执行,执行完毕时由线程库回收其资源。

3.mutex:互斥锁
想实现让多个线程互斥的访问某段代码,可以使用互斥锁,如下:

mutex m;

void fun(){
    m.lock();
    fun_other_thing();
    m.unlock();
}

线程申请该互斥锁,如果成功获取,则一直拥有直到该线程调用unlock()解锁。如果无法申请到,则线程阻塞在该过程中。

4.lock_guard
在前面的代码中,如果fun_other_thing()函数的代码有问题,把么unlock()函数无法正常解锁,为了避免这种异常情况导致的无法解锁,可以使用lock_guard()自动加锁解锁,其原理和智能指针类似,会在构造函数里自动加锁,当lock_guard()出了局部作用域,会自动调用析构函数解锁。

mutex m;

void fun(){
    lock_guard<mutex> loc(m); //构造函数里自动加锁
    fun_other_thing();
    //出了这个局部作用域,自动解锁
}

5.unique_lock
lock_guard只能保证在析构时执行解锁操作,其本身并没有提供加锁和解锁的接口,为了提高并发度,需要尽可能减少锁定的区域,也就是减少锁的粒度。unique_lock弥补了这一缺点,提供了lock()和unlock()的接口,如下例子:

mutex m;

void fun(){
    unique_lock<mutex> loc(m);
    fun_other_thing1();
    loc.unlock();

    fun_other_thing2();

    loc.lock();
    fun_other_thing3();
    loc.unlock();
}

这个例子中,只锁住了fun_other_thing1()和fun_other_thing3()两个函数,减小了锁的粒度。

6.条件变量condition_variable
一般情况下,unique_lock和condition_variable配合使用,当条件变量的wait()函数被调用前,它使用unique_lock来锁住当前线程,进入wait函数后,首先会检查条件是否为真,如果不为真,则wait会阻塞并释放锁,允许其他线程继续执行,直到另外一个线程在相同的条件变量上调用了notification()来唤醒当前线程,此时返回while循环判断里。下面通过一个例子来实现三个线程的顺序执行:

class Foo {
private:
    mutex m;                      // 互斥锁
    condition_variable cd_v;      // 条件变量
    bool flag1;                   // 两个标志位
    bool flag2;
public:
    Foo() : flag1(false), flag2(false) {}   // 初始化两个标志位为false,表示second和third还不能打印

    void first() {
        for (int i = 0; i < 10; ++i) {
            cout << "first" << endl;
        }
        flag1 = true;        // first执行后,将flag1置为true,表示second可以打印了
        cd_v.notify_all();   // 唤醒等待队列里的所有线程
    }

    void second() {
        unique_lock<mutex> loc(m);	// 加锁
        // (1)先判断flag1是否为true,true则不会进入while循环,顺利向下执行;
        // (2)flag1为false则执行wait函数,释放锁,并进入对象cd_v的等待队列;
        // (3)当别的线程调用对象cd_v的notify_all()函数,会唤醒在等待队列中的这个线程,然后重新获得锁,继续步骤(1)
        while (flag1==false) {
            cd_v.wait(loc);
        }        
        for (int i = 0; i < 10; ++i) {
            cout << "second" << endl;
        }
        flag2 = true;      // 修改flag2表示third已经可以打印了
        cd_v.notify_all(); // 唤醒等待队列里的所有线程
        // 当离开这个局部作用域时,unique_lock会调用析构函数,自动解锁
    }    

    void third() {
        unique_lock<mutex> loc(m);
        while (flag2==false) {
            cd_v.wait(loc);
        }
        for (int i = 0; i < 10; ++i) {
            cout << "third" << endl;
        }
    }
};


int main() {
    Foo F;  
    thread(&Foo::third, &F).detach();
    thread(&Foo::first, &F).detach();
    thread(&Foo::second, &F).detach();               
    system("pause");   
}

我们设置两个标志位,flag1为true表示允许second打印、flag2为true表示允许third打印,先初始化flag1、flag2为false。线程创建后,可能出现以下的执行顺序:

  • 如果在first()还未打印完时(或者压根还没开始执行时),执行second(),由于此时flag1被初始化为false,进入while循环,调用条件变量的wait函数,会将当前线程阻塞起来,并释放锁。此时需要等另一个子线程执行first()打印完毕后,将flag1设为true,然后调用notify_all()将阻塞的线程唤醒,second()才能接着执行,完成打印。
  • 如果first()已经执行完毕,然后执行second(),那么flag1为true,执行second()的那个线程不会进入while循环,也不会被阻塞起来,能顺利执行。
  • third()的执行先后也可参考以上两种情况。

7.wait函数
wait()会去检查这些条件(通过调用所提供的lambda函数),当条件满足(lambda函数返回true)时返回。如果条件不满足(lambda函数返回false),wait()函数将解锁互斥量,并且将这个线程(上段提到的处理数据的线程)置于阻塞或等待状态。当准备数据的线程调用notify_one()通知条件变量时,处理数据的线程从睡眠状态中苏醒,重新获取互斥锁,并且对条件再次检查,在条件满足的情况下,从wait()返回并继续持有锁。当条件不满足时,线程将对互斥量解锁,并且重新开始等待。

8.生产者和消费者模型
生产者向队列中插入数据,消费者则在生产者发出队列准备好了后接收消息,然后取出数据进行处理:

#include <pthread.h>
struct msg {
struct msg *m_next;
/* ... more stuff here ... */
};
struct msg *workq;
pthread_cond_t qready = PTHREAD_COND_INITIALIZER;
pthread_mutex_t qlock = PTHREAD_MUTEX_INITIALIZER;

void process_msg(void)
{
    struct msg *mp;
    for (;;) {
        pthread_mutex_lock(&qlock);
        while (workq == NULL)
            pthread_cond_wait(&qready, &qlock);
        mp = workq;
        workq = mp->m_next;
        pthread_mutex_unlock(&qlock);
        /* now process the message mp */
    }
}

void enqueue_msg(struct msg *mp)
{
    pthread_mutex_lock(&qlock);
    mp->m_next = workq;
    workq = mp;
    pthread_mutex_unlock(&qlock);
    pthread_cond_signal(&qready);
}
  • 为什么在wait函数前需要加锁
    在wait函数前加锁是为了保护条件变量,调用wait函数进行等待条件的发生时,mutex会被自动释放,以供其他线程改变条件,其中两个步骤必须是原子性的:把调用线程放到条件等待队列上,以及释放锁。
    如果不是原子性的,比如先释放锁,这时候生产者向队列中添加数据,然后signal,之后消费者线程才去把调用线程放到等待队列上,signal信号就会丢失;如果先把调用线程放到条件等待队列上,这时候另外一个线程发送了signal,然后调用线程立即获取到锁,两次获取mutex会产生deadlock。

  • 生产者线程中修改条件为什么要加mutex
    如果不这么做信号可能会丢失,如下:

    Thead A Thread B

    pthread_mutex_lock(&qlock);
    while (workq == NULL)
    mp->m_next = workq;
    workq = mp;
    pthread_cond_signal(&cond);
    pthread_cond_wait(&qready, &qlock);

在while判断之后向队列中插入数据,虽然已经有了数据,但是线程A还是调用了wait函数去等待下一个信号到来,究其原因是B线程没有获取到锁就进行了操作,应该等到wait函数释放锁之后再进行插入数据的操作。

  • 消费者线程中判断条件为什么要放在while中
    一个生产者可能对应多个消费者,生产者向队列中插入一条数据之后发出signal,然后各个消费者线程的wait函数获取mutex后返回,这里只有一个线程可以成功,如果这里使用了if,那么就会在当前队列为空的状态下继续执行,显然是不合理的。

  • signal是放在unlock之前还是之后

(1):先unlock再signal

void enqueue_msg(struct msg *mp)
{
    pthread_mutex_lock(&qlock);
    mp->m_next = workq;
    workq = mp;
    pthread_mutex_unlock(&qlock);
    pthread_cond_signal(&qready);
}

如果unlock后恰好有一个消费者获取mutex,然后进入条件判断,发现队列不为空,就会跳过wait函数,那么下面的signal就会不起作用;另外一种情况,一个优先级更低的不需要条件判断的线程也正好获取到了锁,那么就会去执行这个优先级低的线程,违背了设计的初衷

(2):先signal再unlock

void enqueue_msg(struct msg *mp)
{
    pthread_mutex_lock(&qlock);
    mp->m_next = workq;
    workq = mp;
    pthread_cond_signal(&qready);
    pthread_mutex_unlock(&qlock);
}

如果把signal放在unlock之前,消费者线程会被唤醒,发现mutex获取不到,则又去sleep了,浪费了资源,但是在Linux下,就不会有这个问题。因为在Linux 线程中,有两个队列,分别是cond_wait队列和mutex_lock队列,cond_signal只是让线程从cond_wait队列移到mutex_lock队列,而不用返回到用户空间,不会有性能的损耗。所以在Linux中推荐使用这种模式。

操作系统的IO分类

1.缓冲与非缓冲IO(减少系统调用次数)

  • 缓冲 I/O,利用的是标准库的缓存实现文件的加速访问,而标准库再通过系统调用访问文件。

  • 非缓冲 I/O,直接通过系统调用访问文件,不经过标准库缓存。
    2.直接与非直接IO

  • 直接 I/O,不会发生内核缓存和用户程序之间数据复制,而是直接经过文件系统访问磁盘。

  • 非直接 I/O,读操作时,数据从内核缓存中拷贝给用户程序,写操作时,数据从用户程序拷贝给内核缓存,再由内核决定什么时候写入数据到磁盘。
    3.阻塞与非阻塞IO

关于缓存命中

1.CPU cache每次加载的数据长度是固定的,比如64字节,那么即是我们只请求了4字节的数据,它也会读取64字节的内容到L1 缓存中,方便之后再请求数据的时候先从L1 缓存中查找。其中L1缓存分为数据缓存和指令缓存。

  • 提升数据缓存命中率:按照内存布局的顺序访问,比如访问二维数组,则按照行排列的方式去访问数据。
  • 提升指令缓存命中率:按照有规律的分支语句可以提升CPU的分支预测器的执行效率,如果分支预测可以预测到接下来要执行 if 里的指令,还是 else 指令的话,就可以「提前」把这些指令放在指令缓存中,这样 CPU 可以直接从 Cache 读取到指令,于是执行速度就会很快。

伪共享的问题

多个线程同时读写同一个Cache Line的不同变量,而导致Cache Line失效的问题,称为伪共享。避免方法为将两个有可能地址连续的变量,使用对齐地址的方法使得二者不在一个Cache Line中,例如:

struct test{
    int a;
    int b __cacheline_aligned_in_smp;
};

CPU对于线程的选择

1.完全公平调度:CFS方法,对于普通任务,为其安排一个虚拟运行时间,如果一个任务在运行,那么运行时间越长,其虚拟运行时间就越大,如果一个任务没有被运行,其虚拟运行时间就不变。CPU优先会选择虚拟运行时间较短的任务来运行。
2.CPU运行队列:实际上任务数要远大于CPU核心数,这时候就需要队列来排队。每个CPU都有自己的运行队列,其中分为三个队列:Deadline运行队列,Realtime运行队列和CFS运行队列,其中CFS就是用红黑树来进行描述的,其最左侧的叶子节点就是下一次最先被调度的任务。当然这三个队列的优先级是不一样的,Deadline > Realtime > CFS

C++内存优化

1.编译器优化:开启O2优化选项
2.算法和数据结构的优化

  • 如果是确定的数据长度,主要做查询工作,建议使用数组来完成
  • 如果时插入删除为主,需要使用链表的操作
  • 结构体里面按照CPU位长对齐
    3.函数优化:使用内联函数,使用引用传递或者移动语义
    4.类:定义移动构造和移动赋值运算符,避免不必要的复制操作

C++中有几种类型的new

1.plain new:也就是我们常用的new,其在空间分配失败的情况下返回bad_alloc而不是NULL
2.nothrow new:空间分配失败的情况下,不抛出异常,而是返回NULL
3.palcement new:主要用途就是反复使用一块较大的动态分配的内存,来构造不同类型的对象或者他们的数组,需要显式的调用析构函数来销毁,不能使用delete,因为其构造的对象或者数组的大小并不一定等于原来分配的内存大小,使用delete会造成内存泄漏,或者释放内存出现运行时错误。

static的作用

1.隐藏:编译多个文件时,没有static的全局变量和函数都具有全局可见性,而加了的只有在本文件中才可见
2.全局变量和static变量都在静态变量区,热着都会在程序刚开始运行就完成初始化,也是唯一一次初始化,值为0(没有给定值的情况下)
3.类中的static成员变量属于整个类所有,类的所有对象只有一份拷贝;类的static成员函数属于整个类所有,函数没有this指针,因而只能访问类的static成员变量
4.类的static成员变量必须先初始化再使用,只能在类体外进行初始化。
5.static成员函数不能被virtual修饰,因为static不属于任何对象或者实例,所以加virtual没有意义。况且static函数没有this指针,而虚函数的指向函数表的指针是通过this指针指向的,所以无法实现。

类成员初始化方式?构造函数的执行顺序?为什么使用成员初始化列表会快一些

1.初始化方式

  • 赋值初始化:通过函数体内进行赋值初始化
  • 列表初始化:在冒号后使用初始化列表进行初始化
    二者的区别:在函数体中初始化,是在所有的数据成员被分配内存空间后进行的;而列表初始化是给数据成员分配内存空间时就进行初始化,此时函数体还未执行。
    2.一个派生类构造函数的执行顺序:
  • 虚拟基类的构造函数,如果有多个,就按照继承的顺序执行构造函数
  • 基类的构造函数(多个普通基类也按照继承的顺序执行构造函数)
  • 类类型的成员对象的构造函数(按照初始化顺序)
  • 派生类自己的构造函数
    3.因为方法一是在构造函数中进行赋值操作,会产生临时对象,降低程序的效率。而成员初始化列表对于类类型,实际上是少了一次调用构造函数的过程,对于内置数据类型二者没有区别。

必须使用成员列表初始化的情况

1.初始化一个引用成员
2.初始化一个常量成员
3.该类是继承一个基类,并且基类中有构造函数,构造函数有一组参数时
4.该类的成员变量类型是类类型,而该类类型的构造函数带参数时

C++ 赋值,浅拷贝,深拷贝,零拷贝

1.浅拷贝:只复制指向某个对象的指针,而不是复制对象本身,新旧对象还是共享同一块内存
2.深拷贝:会另外创造一个一样的对象,和原本的对象不共享内存,修改新对象不会修改旧对象
3.零拷贝:操作系统中的mmap操作

coredump错误

程序由于异常或者bug在运行时异常退出或终止,在一定条件下生成一个叫core的文件,该文件会记录程序在运行时的内存,寄存器状态,内存指针和函数堆栈信息等。

静态类型和动态类型,静态绑定和动态绑定

  • 静态类型:对象在声明时采用的类型,在编译期已经确定
  • 动态类型:通常是指针或者引用…目前所指向的对象类型,在运行期决定
  • 静态绑定:发生在编译期,绑定的是静态类型
  • 动态绑定:绑定的是动态类型,发生在运行期
  • 非虚函数一般都是静态绑定,虚函数都是动态绑定

怎样判断两个浮点数是否相同

通过相减来和预先设定的精度比较

100层,两个小球

每次从第k层开始,那么如果碎了,就可以从1到(k - 1)逐次扫描,
下一次从k + (k - 1)开始,如果碎了,那么就可以从k + 1到2k - 2,加上之前第一次也总共是K次
所以k + k - 1 + k - 2 … <= 100,那么k最大可以取14

手动实现strcpy(注意异常情况)

void mystrcpy(char *dst, const char *src){
    assert(src != NULL);
    assert(dst != NULL);
    while(*dst++ = *src){
        ;
    }
}

或者

char* mystrcpy(char *dst, const char *src){
    assert(src != NULL);
    assert(dst != NULL);
    char* start = dst;
    while(*dst++ = *src){
        ;
    }
    return start;
}

为什么析构函数一般写成虚函数

由于类的多态性,基类指针可以指向派生类的对象,如果删除该基类的指针,就会调用该指针指向的派生类析构函数,而派生类的析构函数又自动调用基类的析构函数,这样整个派生类的对象完全被释放。
如果析构函数不被声明成虚函数,则编译器实施静态绑定,在删除基类指针时,只会调用基类的析构函数而不调用派生类析构函数,这样就会造成派生类对象析构不完全,造成内存泄漏。

基类的虚函数表存放在内存的什么区,虚表指针vptr的初始化时间

1.虚函数表是全局共享的元素,即全局仅有一个,在编译时就构造完成
2.C++中虚函数表位于常量区;而虚函数则位于代码段(.text),也就是C++内存模型中的代码区。

构造函数、析构函数、虚函数可否声明为内联函数

1.将构造函数和析构函数声明为inline是没有什么意义的,即编译器并不真正对声明为inline的构造和析构函数进行内联操作,因为编译器会在构造和析构函数中添加额外的操作(申请/释放内存,构造/析构对象等),致使构造函数/析构函数并不像看上去的那么精简。其次,class中的函数默认是inline型的,编译器也只是有选择性的inline,将构造函数和析构函数声明为内联函数是没有什么意义的。
2.当是指向派生类的指针(多态性)调用声明为inline的虚函数时,不会内联展开;当是对象本身调用虚函数时,会内联展开,当然前提依然是函数并不复杂的情况下。

C++模板是什么,你知道底层怎么实现的?

编译器并不是把函数模板处理成能够处理任意类的函数;编译器从函数模板通过具体类型产生不同的函数;编译器会对函数模板进行两次编译:在声明的地方对模板代码本身进行编译,在调用的地方对参数替换后的代码进行编译。

构造函数、析构函数的执行顺序?构造函数和拷贝构造的内部都干了啥?

1.构造函数顺序

  • 基类构造函数。如果有多个基类,则构造函数的调用顺序是某类在类派生表中出现的顺序,而不是它们在成员初始化表中的顺序。
  • 成员类对象构造函数。如果有多个成员类对象则构造函数的调用顺序是对象在类中被声明的顺序,而不是它们出现在成员初始化表中的顺序。
  • 派生类构造函数。
    2.析构函数顺序
  • 调用派生类的析构函数;
  • 调用成员类对象的析构函数;
  • 调用基类的析构函数。

单例模式的两种实现

1.懒汉方式:单例实例在第一次被使用的时候才进行初始化,称为延迟初始化

class Singleton{
private:
    Singleton(){};
    ~Singleton(){};
    Singleton(const Singleton&);
    Singleton& operator=(const Singleton&);

public:
    static Singleton& getInstance(){
        static Singleton instance;
        return instance;
    }
}

使用了局部静态变量的方式来保证线程安全

2.饿汉模式:单例模式在程序运行时立即被初始化

class Singleton{
private:
    Singleton(){};
    ~Singleton(){};
    Singleton(const Singleton&);
    Singleton& operator=(const Singleton&);
private:
    static Singleton instance;
public:
    static Singleton& getInstance(){
        return instance;
    }
}

//初始化
Singleton Singleton::instance;

问题:没有线程安全问题,但是static Singleton instance 和 static Singleton& getInstance()二者的初始化顺序不确定,如果在初始化完成之前调用 getInstance() 方法会返回一个未定义的实例。

shared_ptr的实现

template<typename T>
class SharedPtr{
public:
    SharedPtr(T* ptr = NULL):_ptr(ptr), _pCount(new int(1)){}
    SharedPtr(const SharedPtr& s):_ptr(s._ptr), _pCount(s._pCount){
        (*_pCount)++;
    }

    SharedPtr<T>& operator=(const SharedPtr& s){
        if(_ptr_ != s._ptr){
            if(--(*_pCount) == 0){
                delete _ptr;
                delete _pCount;
            }
            _ptr = s._ptr;
            _pCount = s._pCount;
            (*_pcount)++;
        }
        return *this;
    }

    T& operator*(){
        return *ptr;
    }

    T* operator&(){
        return ptr;
    }

    ~Shared(){
        --(*_pCount);
        if(*_pCount == 0){
            delete _ptr;
            delete _pCount;
            _ptr = nullptr;
            _pcount = nullptr;
        }
    }

private:
    T* _ptr;
    unsigned_int* _pCount;
}

shared_ptr指针的大小

8字节:一个指针,指向被保护的对象,还有一个计数类,其实现是一个指针,指向两个int类型的变量,分别是shared_ptr的引用计数和weak_ptr的数量

vector扩容什么时候使用移动构造

如果vector内保存的是一个类类型,并且该类类型的移动构造函数声明为 noexcept,则会调用移动构造(浅拷贝),否则调用深拷贝,即拷贝构造,这样会大大浪费资源

稳定排序和不稳定排序

1.稳定排序:能保证两个相等的元素在排序之后前后顺序不变

  • 冒泡排序:因为每次遇到相等判断,是不会发生交换的
  • 插入排序:如果比当前的最大者还大,就插在后面,遇到相等的情况,则插入到相等元素的后面,能保证顺序不变
  • 归并排序:在合并的过程中,遇到两个相等的元素,会把处在前面的元素先保存,最终保证了稳定性

2.不稳定排序:

  • 快速排序:在中枢元素和交界元素交换的时候会破坏稳定性
  • 堆排序

一个很大的二维数组,里面大部分数据都是0,怎么保存

可以使用数组压缩的方式来解决,压缩后的格式为:第一行保存的是原始数组的行列数,和有效数据数,接下来的每一行保存的是原始数据中某个有效数据的行列以及数值。这样使用 n * 3的数组就可以保存原来的数据

编写String类的构造函数,析构函数和赋值函数

class String{
private:
    char* m_data;
public:
    String(const char* str = nullptr){};
    String(const String& str);
    ~String(){};
    String& operator=(const String& str);
}

String::String(const char* str){
    if(NULL == str){
        m_data = new char[1];
        *m_data = '\0';
    }else{
        int len = strlen(str);
        m_data = new char[len + 1];
        strcpy_s(m_data, len + 1, str.m_data);
    }
}

String::String(const String& str){
    int len = strlen(str.m_data);
    m_data = new char[len + 1];
    strcpy_s(m_data, len + 1, str);
}

String::~String{
    if(m_data != nullptr){
        delete[] m_data;
        m_data = nullptr;
    }
}

String& String::String(const String& str){
    //如果与str对象时一样的,即自赋值
    if(this == &str){
        return *this;
    }
    delete[] m_data;
    int len = strlen(str.m_data);
    m_data = new char[len + 1];
    strcpy_s(m_data, len + 1, str.m_data);
    return *this;
}

实现一个unique_ptr

template<typename T>
class MyUniquePtr
{
public:
    MyUniquePtr(T* ptr = nullptr):mPtr(ptr){};
    ~MyUniquePtr(){
        if(mPtr){
            delete mPtr;
            mPtr = nullptr;
        }
    }
    MyUniquePtr(MyUniquePtr &&P) noexcept;
    MyUniquePtr& operator=(MyUniquePtr &&p) noexcept;

    MyUniquePtr(const MyUniquePt &p) = delete;
    MyUniquePtr& operator=(const MyUniquePtr &p) = delete;

    void reset(T *q = nullptr) noexcept{
        if(q != mPtr){
            if(mPtr){
                delete mPtr;
            }
            mPtr = q;
        }
    }

    T* release() noexcept{
        T* res = mPtr;
        mPtr = nullptr;
        return res;
    }

    void swap(MyUniquePtr &p) noexcept{
        using std::swap;
        swap(mPtr, p.mPtr);
    }

private:
    T* mPtr;
};

template <typename T>
MyUniquePtr<T>& MyUniquePtr<T>::operator=(MyUniquePtr &&p) noexcept{
    swap(*this, p);
    return *this;
}

template <typename T>
MyUniquePtr<T>::MyUniquePtr(MyUniquePtr &&p) noexcept: mPtr(p.mPtr){
    p.mPtr = nullptr;
}

前序和后序不能确定二叉树

前序和后序在本质上都是将父节点与子结点进行分离,但并没有指明左子树和右子树的能力,因此得到这两个序列只能明确父子关系,而不能确定一个二叉树。

实现LRU

struct DLinkedNode {
int key, value;
DLinkedNode* prev;
DLinkedNode* next;
/* 构造函数便于新定义节点时初始化对象 */
DLinkedNode(): key(0), value(0), prev(nullptr), next(nullptr) {}
DLinkedNode(int _key, int _value): key(_key), value(_value), prev(nullptr), next(nullptr) {}
};

class LRUCache {
private:
unordered_map<int, DLinkedNode*> map;
DLinkedNode* head;
DLinkedNode* tail;
int size; // 缓冲区使用的大小
int capacity; // 缓冲区的容量

public:
LRUCache(int _capacity): capacity(_capacity), size(0) {
/* 使用伪头部和伪尾部节点 /
head = new DLinkedNode();
tail = new DLinkedNode();
head->next = tail;
tail->prev = head;
}
/
如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1 /
int get(int key) {
if (!map.count(key)) return -1;
/
如果 key 存在,先通过哈希表定位,再移到头部 /
DLinkedNode
node = map[key];
moveToHead(node);
return node->value;
}

void put(int key, int value) {
    if (!map.count(key)) {
        /* 如果 key 不存在,创建一个新的节点 */
        DLinkedNode* node = new DLinkedNode(key, value);
        /* 添加进哈希表 */
        map[key] = node;
        /* 添加至双向链表的头部 */
        addToHead(node);
        /* 缓存大小+1 */
        size++;
        if (size > capacity) {
            DLinkedNode* removed = removeTail();// 如果超出容量,删除双向链表的尾部节点
            map.erase(removed->key);// 删除哈希表中对应的项
            delete removed;// 防止内存泄漏
            size--;// 缓存大小-1
        }
    }
    else {
        /* 如果 key 存在,先通过哈希表定位,再修改 value,并移到头部 */
        DLinkedNode* node = map[key];
        node->value = value;
        moveToHead(node);
    }
}

/定义双向链表需要用的API函数/
public:
/* 在虚拟头节点后添加新的节点 node /
void addToHead(DLinkedNode
node) {
node->prev = head;
node->next = head->next;
head->next->prev = node;
head->next = node;
}
/* 删除当前节点 node 这里也是为什么使用双向链表的原因方便删除节点 /
void removeNode(DLinkedNode
node) {
node->prev->next = node->next;
node->next->prev = node->prev;
}
/* 把出现频率比较高的节点放入 头部 /
void moveToHead(DLinkedNode
node) {
removeNode(node);
addToHead(node);
}
/* 移除尾部节点 并返回尾部最后一个节点 /
DLinkedNode
removeTail() {
DLinkedNode* node = tail->prev;
removeNode(node);
return node;
}
};

快速排序

void Quick_Sort(int *arr, int begin, int end){
if(begin > end)
return;
int tmp = arr[begin];
int i = begin;
int j = end;
while(i != j){
while(arr[j] >= tmp && j > i)
j–;
while(arr[i] <= tmp && j > i)
i++;
if(j > i){
int t = arr[i];
arr[i] = arr[j];
arr[j] = t;
}
}
arr[begin] = arr[i];
arr[i] = tmp;
Quick_Sort(arr, begin, i-1);
Quick_Sort(arr, i+1, end);
}

stl常用容器在内存中的位置

  • 在栈区定义容器变量,变量本身存储在栈区,但是变量存储的数据在堆区;
  • 在堆空间定义的容器变量,变量本身存储在堆区,存储的数据也在堆区;
0%