2023届C/C++软件开发工程师校招面试常问知识点复盘Part 7

46、C++类的成员变量初始化顺序及拓展

注意:

1、const成员或者引用必须在成员变量初始化列表中初始化,不可以在构造函数中初始化

  • 为什么呢?因为在构造函数中的“初始化”,其实不是初始化而是赋值,而const变量不允许被赋值
  • 引用本质上又是const 指针,与上述同理

2、static成员变量必须在类的外部初始化

3、没有默认构造函数的自定义类型必须在初始化列表中初始化,原因在本小节最后


初始化顺序要点:

1、类的成员变量的初始化顺序与类定义中的成员变量的顺序有关,与初始化列表中的顺序无关

  • 也即类定义中如果先定义变量int a;后定义变量int b;那么在程序编译阶段就已经决定的成员变量的存放次序了,因此肯定时首先初始化a,然后初始化b

2、如果类的成员变量在构造函数中初始化,那么将会按照在构造函数中的顺序进行初始化


如果带上静态变量的话,其初始化顺序为:

1、基类的静态变量或全局变量

2、派生类的静态变量或全局变量

3、基类的成员变量

4、派生类的成员变量


成员初始化列表与构造函数内进行初始化的区别?

  1. 类的成员变量其实在进入构造函数的函数体内前就已经完成了初始化;如果有成员初始化列表(称为显式的初始化)就会按照列表进行初始化,如果没有显式的初始化也即没有初始化列表,那么编译器将使用默认的构造函数进行初始化。

  2. 因此构造函数内的初始化本质上是一种赋值

  3. 对于内置类型、引用和指针,两种方式在效率和结果上是一样的

  4. 最大的区别在于自定义类型成员变量

    • 如果使用初始化列表来初始化自定义类型,只会发生一次构造,比如拷贝构造,这在进入构造函数前就已经完成
    • 如果没有使用初始化列表,在进入构造函数之前,编译器调用默认的构造函数先初始化一次成员变量,这是第一次构造;然后进入构造函数内部又一次赋值,这就产生了第二次构造,这在效率上是低下的。因为发生了两次构造,因此推荐对自定义类型使用初始化成员列表进行初始化

另一个问题,如果自定义类型的成员变量没有默认构造函数,那么必须在初始化列表中显式的初始化,否则就会因为编译器找不到默认构造函数而报错

47、强制转换类型操作符号
  1. static_cast<newType>(data)

    • 一般用于良性转换

    • 原有的不会发生什么意外的转换:int–>double、short–>int、const–>非const、向上转换(派生类到基类的转换)

    • void*–>int*int*–>void*类似这种void指针与具体类型之间的转换

    • 存在转换构造函数类型转换函数的类与其他类型之间的转换:double与Complex的转换

      不可以用于无关类型之间的转换,因为无关类型之间的转换是有风险的

      比如:具体类型的之间的转换、整数到指针的转换

  2. const_cast<newType>(data)

    • 一般用于将const/volatile特性抹去,也即将const/volatile类型变成非const/volatile类型

    • 通常情况下是newType是指针类型,也即将将一个指向const类型的指针,转换为一个指向非const类型的指针

      const int n = 15;
      int * p = const_cast<int*>(&n);
      // 注意:&n的类型是const int*   p的类型是int* 
      
  3. reinterpret_cast<newType>(data)

    • 是一种简单粗暴的转换方式,或者说并没有基于转换规则进行转换,而是直接在二进制层面的重新解释

    • 可以做一些正常的转换不被允许的转换:如把一个不同类型的指针的相互转换一个类的指针转换为一个普通类型的指针

  4. dynamic_cast<newType>(data)

    • 是用于在类的继承关系之间进行转换的,既可以向上转换(派生类–>基类),又可以向下转换(基类—>派生类)

      • 向上转换是无条件的,因为一定会成功,等价与static_cast<>()
      • 向下转换是有条件的,必须是安全的,要借助RTTI进行检测,因此只有部分会成功
    • 该转换要求转换双方必须是指针引用

      • 对于指针,转换失败将返回NULL
      • 对于引用,转换失败将返回std::bad_cast异常
    • dynamic_cast的工作过程:

      1. 在程序的运行过程中会遍历继承链,如果途中遇到了要转换的目标类型,那么就能够转换成功
      2. 如果直到继承链的最顶端也即最顶端的基类时依旧没有找到要转换的目标类型,那么就转换失败了
    • 在本质上,dynamic_cast还是只能向上转换。而所谓的基类转换到派生类,只不过是用不同的基类指针指向一个派生类而已

      : class A--->class B--->class C--->class D
          A* pa = new D();
      	B* pb;
      	C* pc;
      	D* pd;
      
      	pb = dynamic_cast<B*>(pa);  // pa 向下转换为pb  不过这些都是表现  本质就是不同的基类指针指向派生类而已
      	pc = dynamic_cast<C*>(pa);
      	pd = dynamic_cast<D*>(pa);
      

int 转换为 float 有损失精度的风险, int转double 没有损失精度的风险

int和float一般都是32位存储的,但是存储格式有不同之处

  1. int: 首位是符号位,1~31位都用来表示数据,因此int所能表示数据范围是 − 2 31 -2^{31} 231–> 2 31 − 1 2^{31}-1 2311,也即-2,147,483,648 到 2,147,483,647

  2. float: 从高位到低位依次是符号阶码尾数(1 ,8,23),因此指数最大部分也只是23次方

  • 综合上述的① 和 ②, 我们发现int所能表示的数据的范围要比float更大,因此int转为float时有发生损失精度的风险; 而将int转换成double,64位,高位到低位依次是符号阶码尾数(1,11,52)时不会损失精度,因为double的指数部分最高有52次方。
 cout << "测试 int ---> float的转换\n";
 int MAX = INT_MAX; // 设置int为最大值
 float MAXf = (float)MAX;
 cout << MAX << " " << setprecision(9) << MAXf << endl;
 cout << (int)MAXf << endl; // -2147483648 与 INT_MAX已经不一样了,说明转换到float后精度损失了

 double MAXd = (double)MAX;
 cout << MAX << " " << setprecision(9) << MAXd << endl;
 cout << (int)MAXd << endl; // 2147483647 == INT_MAX 说明没有发生精度损失
48、const 成员函数–常成员函数与常量对象
  • 常量对象:
const Obj;
  1. 给某个对象加上const进行修饰,表示Obj在初始化之后就不希望对其普通的成员变量进行修改
  2. 常量对象的修饰对静态成员变量起作用吗?答:不起作用,因为const此时修饰的是这个实例化对象,并不是修饰的类,然后静态成员变量属于这个类而不属于这个对象。
  • 常量对象不能调用普通成员函数可以调用常量成员函数静态成员函数
  1. 因为普通成员函数在执行过程中有可能修改对象的成员
  2. 常量成员函数则可以保证不会修改对象的成员,因此可以调用常量成员函数
  3. 静态成员函数独立于类的对象,属于类,因此不能访问非静态的成员变量,自然也就没有修改非静态成员变量的可能,因此可以被常量对象调用
  • 常量成员函数—如下int getAValue() const
class A{
    int a;
public:
    A() : a(0) {}
    int getAValue() const{
       return a; 
    }
};
  1. 函数后方的const表明这是一个常量成员函数
  2. 常量成员函数不会改变对象的成员变量
  3. 常量成员函数主要被常量对象所调用
  4. 如果编译器发现常量成员函数内部有修改非静态成员变量的行为就会报错,而内部修改静态成员变量是不会报错的
    • 因为类的静态变量并不受const对象约束
#include <iostream>
using namespace std;

class Test
{
public:
    static int a;
    int b;

public:
    Test() : b(0) {}
    // 常量成员函数
    void changeStatic(int _a) const
    {
        a = _a; // 此处在const成员函数修改了静态变量,允许。
        // b++;    // 修改非静态成员变量,不允许
    }
    // 普通函数
    void changeB()
    {
        b++;
    }
    static void setStatic()
    {
        a--;
    }
};
int Test::a = 100; //静态类成员的类外初始化

int main()
{
    Test t;
    cout << t.b << " " << t.a << endl;

    const Test conT;
    conT.changeStatic(111); // 常量对象调用常量成员函数
    // conT.changeB();         // 常量对象不允许调用普通成员函数
    cout << conT.a << endl;

    conT.setStatic(); // 常量对象可以调用静态函数
    return 0;
}
  • 关于重载:const修饰可以作为函数重载的区分标志
    • 常量对象调用const版本
    • 非常量对象调用普通版本
  • 如果一个函数不会修改类的成员变量,可以将其定义为const类型,这是一个好习惯
    • 因为普通对象和const对象都可以调用const成员函数
49、volatile关键字

声明语法:int volatile vInt;

  • 当使用该关键字进行声明时,系统总是会重新的从他所在的内存读取数据,即使他前面的指令刚刚从该处读过数据;
  • volatile用于告知编译器该变量是随时可变的,因此禁止了编译器的优化,让程序每次都去读取最新的数据
    • (有时候编译器会根据代码结构进行优化,而不去读取寄存器或内存位置的最新数据)

一般来讲volatile的使用场景为:

  1. 中断服务程序中修改的供其他程序检测的变量需要加volatile
  2. 多任务环境下各个任务间共享的标志或数据应该加volatile
  3. 存储器映射的硬件寄存器通常也需要加volatile说明,因此每次对他的读写都可能意义不同

volatile也可以像const一样修饰指针指向的变量

const int* pInt;   // 表示pInt指向的数据是一个常量
volatile int* pVInt; // 表示pVInt指向的数据是一个易变的量

// ---------------------------------------------

int* const pInt;		// 表示指针pInt本身是不可变的
int* volatile pVInt; 	// 表示指针pVInt本身是易变的

该关键字指定了某个对象的存储位置在内存中,而不是在寄存器中。因为一般的对象编译器可能会将其拷贝到寄存器中,用以加快指令的执行速度。

主要就是要让编译器每次操作该变量时一定真正从内存中取出,而不是使用已经在寄存器中的值

50、赫夫曼树

赫夫曼树的相关概念:

  1. 路径长度:从一个结点到另一个结点之间的分支构成两个结点之间的路径,路径上分支的数目称为路径长度
  2. 树的路径长度:从树根到每一个结点的路径长度之和
    • 注意是每个结点,而不是每个叶子结点
  3. 结点的带权路径长度:从该结点到树根之间的路径长度结点权重的乘积
  4. 树的带权路径长度:树中所有叶子结点的带权路径长度之和

赫夫曼树的概念:

带权路径长度最小的二叉树称为赫夫曼树

赫夫曼树的构造:———以A(5)、B(15)、C(40)、D(30)、E(10)为例

  1. 先将有权值的叶子结点升序排列
    • {A(5)、E(10)、B(15)、D(30)、C(40)}
  2. 取权值最小的两个结点A(5)、E(10),作为一个新节点N1的子节点
    • 相对较小A(5)的作为左孩子,较大的E(10)作为右孩子
    • 删除序列中两个结点A(5)、E(10)
    • N1(15)添加到序列中,依旧按照升序排列,N1的权值等于左右孩子权值之和
      • {N1(15)、B(15)、D(30)、C(40)}
  3. 再次取权值最小的两个结点N1(15)、B(15),作为一个新节点N2的子节点
    • N1(15)和B(15) 权值一样,因此左右孩子均可
    • 删除序列中的两个结点N1(15)、B(15)
    • 将N2(30)添加到序列中,升序排列,N2的权值等于左右孩子权值之和
      • {N2(30)、D(30)、C(40)}
  4. 重复第2步
    • N3(60)有两个子节点N2和D
      • {C(40)、N3(60)}
  5. 重复第2步
    • N4(100)有两个子节点C(40)N3(60)
      • 此时序列中仅有一个根节点{N4(100)}
  6. 构造完成
			     N4(100)
                 /     \
              C(40)   N3(60)
                     /     \
				  N2(30)  D(30)
                 /     \
              N1(15)   B(15)
              /    \
            A(5)  E(10)

哈夫曼树的特点

1、满二叉树不一定是哈夫曼树

2、哈夫曼树并不唯一,因为有可能存在带权最短路径一样的树,此时也就有了多个哈夫曼树

3、权重较大的叶子节点更靠近根,权重较小的叶子节点距离根可以稍微远一些

4、哈夫曼树的结点的度要么为0,要么为2,没有度为1的结点

5、包含n个结点的森林需要经过n-1次合并,才能形成一颗赫夫曼树,此时共有2n - 1个结点,因此新增加了n - 1个结点

哈夫曼编码

  • 哈夫曼编码最初的设计主要用于长距离通信中的数据压缩问题
  • 因为不管是中文还是英文,不同的字符的使用频率是不一样的,有些使用非常频繁,有些则使用的次数较少;因此如果在编码过程中如果可以将使用频繁的字符用更短的编码,使用次数少的字符可以使用较长的编码,就可以进行数据的压缩,此时就用到了哈夫曼树,其中字符的使用频率就是其权重
  • 每一个字符的编码结果位于叶子结点

哈夫曼编码定义

一般的,设需要编码的字符集为{d1, d2, ··· , dn},每个字符使用的频率集合为{w1, w2, ···, wn},以d1, d2, ··· , dn作为叶子结点,以w1, w2, ···, wn作为叶子的权重,来构造一棵赫夫曼树。

规定赫夫曼树的左分支为代表0右分支代表1,则从根节点到叶子结点所经过的路径分支组成的0和1的序列便为该结点对应字符编码,这就是赫夫曼编码

1、赫夫曼编码是特殊的前缀码,因为只有叶子结点才代表某个字符

2、赫夫曼编码的每一个字符编码都不是其他编码的前缀

51、前缀树
  • 又称为字典树,是一种有序树,用于保存关联数组,其中的键通常就是字符串
  • 前缀树的键并不是直接存储在结点中,而是由结点在树中的位置所决定的
  • 一个结点的所有子孙都用着相同的前缀,这个前缀就是当前结点对应的字符串
  • 根节点对应空字符串
  • 并不是所有的结点都有对应的值,只有叶子结点和部分内部结点所对应的键才有相关的值

在这里插入图片描述

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

咖啡与乌龙

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值