金山云C++面试题及参考答案
C++ 特性及代码演示
C++ 有很多重要的特性,如封装、继承、多态等。
封装是将数据和操作数据的方法组合在一起,通过访问控制符(public、private、protected)来限制对类成员的访问。例如:
class MyClass {
private:
int privateData;
public:
void setData(int data) {
privateData = data;
}
int getData() {
return privateData;
}
};
在这个类中,privateData
是私有成员,只能通过setData
和getData
这两个公共成员函数来访问。
继承允许创建一个新类(子类)从现有类(父类)派生而来,子类继承了父类的成员。例如:
class Parent {
public:
int parentData;
Parent(int data) : parentData(data) {}
};
class Child : public Parent {
public:
int childData;
Child(int pData, int cData) : Parent(pData), childData(cData) {}
};
这里Child
类继承了Parent
类,Child
类的对象可以访问Parent
类的parentData
成员。
多态是指同一个函数名在不同的对象中有不同的实现。最常见的是通过虚函数来实现。例如:
class Shape {
public:
virtual void draw() {
std::cout << "Drawing a shape." << std::cout;
}
};
class Circle : public Shape {
public:
void draw() override {
std::cout << "Drawing a circle." << std::cout;
}
};
当通过基类指针或引用调用draw
函数时,会根据实际对象的类型来调用对应的函数。比如:
Shape* shapePtr = new Circle();
shapePtr->draw();
这里会调用Circle
类中的draw
函数,而不是Shape
类中的draw
函数,这就是多态的体现。
写两个含虚函数的类并让子类继承它们,类中包含 char 和 int 类型
class Base1 {
private:
char base1Char;
int base1Int;
public:
Base1(char c, int i) : base1Char(c), base1Int(i) {}
virtual void func1() {
std::cout << "Base1 function: " << base1Char << " and " << base1Int << std::endl;
}
};
class Base2 {
private:
char base2Char;
int base2Int;
public:
Base2(char c, int i) : base2Char(c), base2Int(i) {}
virtual void func2() {
std::cout << "Base2 function: " << base2Char << " and " << base2Int << std::endl;
}
};
class Derived : public Base1, public Base2 {
private:
char derivedChar;
int derivedInt;
public:
Derived(char c1, int i1, char c2, int i2, char c3, int i3) :
Base1(c1, i1), Base2(c2, i2), derivedChar(c3), derivedInt(i3) {}
void func1() override {
std::cout << "Derived function for Base1: " << (char)(Base1::base1Char + 1) << " and " << (Base1::base1Int + 1) << std::endl;
}
void func2() override {
std::cout << "Derived function for Base2: " << (char)(Base2::base2Char - 1) << " and " << (Base2::base2Int - 1) << std::endl;
}
};
在这个代码中,有Base1
和Base2
两个基类,它们都有自己的私有成员char
和int
类型,并且都有虚函数。Derived
类继承自Base1
和Base2
,在Derived
类中实现了对基类虚函数的重写。在构造函数中初始化了各个类的成员变量,并且在重写的虚函数中可以访问基类的成员变量并进行适当的操作。
C++ 指针如何在类外访问私有成员
在 C++ 中,一般情况下,类外是不能直接访问私有成员的。但是可以通过友元函数或者友元类来实现间接访问。
- 友元函数
class MyClass {
private:
int privateData;
friend void accessPrivateData(MyClass& obj);
public:
MyClass(int data) : privateData(data) {}
};
void accessPrivateData(MyClass& obj) {
std::cout << "Accessed private data: " << obj.privateData << std::endl;
}
在这个例子中,accessPrivateData
函数是MyClass
的友元函数,它可以访问MyClass
对象的私有成员privateData
。
- 友元类
class FriendClass;
class MyClass {
private:
int privateData;
friend class FriendClass;
public:
MyClass(int data) : privateData(data) {}
};
class FriendClass {
public:
void accessData(MyClass& obj) {
std::cout << "Accessed private data: " << obj.privateData << std::endl;
}
};
这里FriendClass
是MyClass
的友元类,FriendClass
的成员函数accessData
可以访问MyClass
对象的私有成员。
另外,还可以通过一些比较特殊的方式,比如通过指针操作内存来访问私有成员,但这是一种很危险的行为,不符合面向对象编程的封装原则,并且可能会导致程序出现不可预测的错误。例如,假设我们有一个类:
class HackableClass {
private:
int privateValue;
public:
HackableClass(int val) : privateValue(val) {}
};
如果我们知道privateValue
在对象内存布局中的偏移量,就可以通过指针来访问它。但是这种方法是不推荐的,因为它依赖于编译器对类的内存布局,不同的编译器可能会有不同的布局方式。而且这种方式破坏了类的封装性。
int main() {
HackableClass obj(42);
int* ptr = (int*)&obj;
std::cout << "Accessed through hack: " << *(ptr) << std::endl;
return 0;
}
在这个例子中,我们通过将对象的地址强制转换为int*
,然后访问了私有成员privateValue
。不过这种方式是非常不正规的,而且很容易导致程序出错。
C++ 常规八股(包括智能指针、移动语义等)
智能指针
智能指针主要用于管理动态分配的内存,它能够自动释放内存,避免内存泄漏。C++ 中有几种智能指针:
std::unique_ptr
- 它拥有对所指向对象的独占所有权。一个对象只能有一个
unique_ptr
指向它。例如:
- 它拥有对所指向对象的独占所有权。一个对象只能有一个
std::unique_ptr<int> ptr1 = std::make_unique<int>(42);
// 不能将ptr1赋值给另一个unique_ptr,除非使用std::move
std::unique_ptr<int> ptr2 = std::move(ptr1);
- 当
ptr2
超出作用域时,它所指向的整数内存会被自动释放。unique_ptr
常用于实现对象的独占资源管理,比如在一个自定义的资源管理类中。
std::shared_ptr
- 它采用引用计数的方式来管理对象。多个
shared_ptr
可以指向同一个对象。例如:
- 它采用引用计数的方式来管理对象。多个
std::shared_ptr<int> ptr3 = std::make_shared<int>(10);
std::shared_ptr<int> ptr4 = ptr3;
- 此时,
ptr3
和ptr4
都指向同一个整数对象,并且引用计数为 2。当最后一个指向该对象的shared_ptr
超出作用域时,对象才会被释放。shared_ptr
在多个对象需要共享同一份资源时非常有用,比如在一个多线程环境下共享的数据结构。
std::weak_ptr
- 它是一种不控制对象生命周期的智能指针,主要用于解决
shared_ptr
的循环引用问题。例如,考虑两个类A
和B
,它们相互包含shared_ptr
成员:
- 它是一种不控制对象生命周期的智能指针,主要用于解决
class A;
class B;
class A {
public:
std::shared_ptr<B> bPtr;
A() {}
~A() {}
};
class B {
public:
std::shared_ptr<A> aPtr;
B() {}
~B() {}
};
- 如果创建
A
和B
的对象并相互指向,它们的引用计数永远不会变为 0,会导致内存泄漏。可以使用weak_ptr
来解决这个问题:
class A;
class B;
class A {
public:
std::weak_ptr<B> bPtr;
A() {}
~A() {}
};
class B {
public:
std::weak_ptr<A> aPtr;
B() {}
~B() {}
};
weak_ptr
可以通过lock
方法来获取一个shared_ptr
,如果对象已经被销毁,lock
方法会返回一个空的shared_ptr
。
移动语义
移动语义主要是为了避免不必要的拷贝操作,提高程序的性能。在 C++ 11 之前,对象的赋值和传递通常是通过拷贝来完成的。例如:
class MyString {
private:
char* data;
size_t length;
public:
MyString(const char* str) {
length = strlen(str);
data = new char[length + 1];
strcpy(data, str);
}
MyString(const MyString& other) {
length = other.length;
data = new char[length + 1];
strcpy(data, other.data);
}
~MyString() {
delete[] data;
}
};
当我们传递一个MyString
对象或者从一个函数返回一个MyString
对象时,会调用拷贝构造函数进行深拷贝。但是在很多情况下,我们并不需要真正的拷贝,比如当一个临时对象即将被销毁时,我们可以直接 “移动” 它的资源。
C++ 11 引入了移动语义,通过右值引用(&&
)来实现。例如,我们可以修改MyString
类的移动构造函数:
MyString(MyString&& other) noexcept {
data = other.data;
length = other.length;
other.data = nullptr;
other.length = 0;
}
当我们有一个临时的MyString
对象时,比如MyString func()
函数返回一个MyString
对象,在合适的情况下会调用移动构造函数,将临时对象的资源直接转移到新的对象中,而不是进行拷贝,从而提高了效率。
另外,还有移动赋值运算符(operator= (MyString&& other)
)也可以实现类似的功能,用于将一个对象的资源移动到另一个对象中,避免不必要的内存分配和数据拷贝。
智能指针对象本身是否线程安全
std::shared_ptr
的引用计数操作是原子的,这使得它在一定程度上是线程安全的。也就是说,当多个线程同时对shared_ptr
进行拷贝构造或者析构操作时(这些操作会涉及引用计数的修改),C++ 标准库保证了引用计数的正确性。
然而,shared_ptr
所指向的对象本身不是线程安全的。例如,如果多个线程通过shared_ptr
访问和修改对象的非原子成员变量,就可能会导致数据竞争和未定义行为。
std::unique_ptr
通常用于独占资源,它的操作本身(如释放资源)一般是在一个线程内完成,不存在多个线程同时访问同一个unique_ptr
的情况。但是如果通过一些不正当的方式(如将unique_ptr
的指针传递给多个线程),也可能会导致问题。
std::weak_ptr
主要用于观察shared_ptr
所管理的对象,它本身的操作(如lock
函数)在引用计数方面是安全的,但是和shared_ptr
类似,对于它所关联的对象的访问也不是线程安全的。
例如,考虑一个shared_ptr
指向一个包含共享资源(如一个计数器)的对象。
class SharedResource {
public:
int counter;
SharedResource() : counter(0) {}
};
void threadFunction(std::shared_ptr<SharedResource> ptr) {
for (int i = 0; i < 1000; ++i) {
ptr->counter++;
}
}
int main() {
std::shared_ptr<SharedResource> sharedPtr = std::make_shared<SharedResource>();
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.push_back(std::thread(threadFunction, sharedPtr));
}
for (auto& th : threads) {
th.join();
}
std::cout << "Counter value: " << sharedPtr->counter << std::endl;
return 0;
}
在这个例子中,多个线程通过shared_ptr
访问并修改SharedResource
对象的counter
成员,这会导致数据竞争。为了避免这种情况,可以使用互斥锁等同步机制来保护对共享对象的访问。
用 new 申请堆内存是否线程安全
在 C++ 中,new
操作符本身不是线程安全的。new
操作符主要用于在堆上分配内存,它包含两个基本操作:一是找到足够大小的内存块,二是对该内存块进行初始化(如果有构造函数的调用)。
从内存分配的角度来看,当多个线程同时调用new
时,可能会出现竞争条件。例如,在一个简单的内存分配器实现中,可能会维护一个空闲内存块的链表。如果两个线程同时请求内存,它们可能会同时访问和修改这个链表,这可能导致内存损坏或者错误的内存分配。
从对象构造的角度,假设有一个类,它的构造函数会对一些共享资源(如全局计数器)进行操作。当多个线程同时使用new
来创建这个类的对象时,构造函数中的共享资源访问可能会导致数据竞争。
为了实现线程安全的内存分配,可以使用互斥锁。例如,在自定义的内存分配器中,可以在分配内存的关键代码段添加互斥锁。
class ThreadSafeAllocator {
private:
std::mutex mutex;
public:
void* allocate(size_t size) {
std::lock_guard<std::mutex> guard(mutex);
// 实际的内存分配逻辑,这里简化为返回nullptr
return nullptr;
}
};
在这个简单的例子中,allocate
函数使用std::lock_guard
来保护内存分配操作,确保在同一时间只有一个线程可以执行内存分配。不过,这种方式会带来一定的性能开销,因为线程需要等待锁的释放。另外,一些操作系统和 C++ 运行时库可能提供了线程安全的内存分配函数,这些函数在底层实现中可能采用了更高效的同步机制,如原子操作或者无锁算法来减少锁竞争带来的开销。
C++ 的 STL 八股
C++ 的标准模板库(STL)是一组功能强大的模板类和函数,用于处理各种常见的数据结构和算法。
容器(Containers)
-
序列容器(Sequential Containers)
- vector:它是一个动态大小的数组。在内存中,元素是连续存储的。这使得它支持快速的随机访问,时间复杂度为 O (1)。例如,
vector<int> v = {1, 2, 3};
,可以通过v[1]
快速访问第二个元素。它的缺点是在中间插入或删除元素可能比较慢,因为需要移动后面的元素,平均时间复杂度为 O (n)。 - list:是一个双向链表。它的优点是在任意位置插入和删除元素都比较快,时间复杂度为 O (1)。例如,
list<int> l = {1, 2, 3};
,可以很容易地在中间插入一个元素。但是它不支持像vector
那样的随机访问,访问元素的时间复杂度为 O (n)。 - deque:双端队列。它结合了
vector
和list
的一些特性。可以在两端快速插入和删除元素,也支持随机访问,不过随机访问的效率比vector
稍低。
- vector:它是一个动态大小的数组。在内存中,元素是连续存储的。这使得它支持快速的随机访问,时间复杂度为 O (1)。例如,
-
关联容器(Associative Containers)
- map:它是一个键 - 值对的容器,以红黑树实现。键是唯一的,并且按照一定的顺序存储。例如,
map<int, string> m; m[1] = "one";
。插入、删除和查找操作的时间复杂度都是 O (log n)。 - set:它是一个只存储键的容器,同样以红黑树实现。例如,
set<int> s = {1, 2, 3};
。它主要用于快速查找元素是否存在,插入、删除和查找操作的时间复杂度都是 O (log n)。
- map:它是一个键 - 值对的容器,以红黑树实现。键是唯一的,并且按照一定的顺序存储。例如,
-
无序关联容器(Unordered Associative Containers)
- unordered_map:它和
map
类似,但是是基于哈希表实现的。插入、删除和查找操作的平均时间复杂度是 O (1),但是在最坏情况下可能是 O (n)。例如,unordered_map<string, int> um; um["apple"] = 1;
。 - unordered_set:和
set
类似,基于哈希表实现。用于快速查找元素是否存在,插入、删除和查找操作的平均时间复杂度是 O (1),最坏情况是 O (n)。
- unordered_map:它和
算法(Algorithms)
STL 提供了大量的通用算法,如sort
用于排序,find
用于查找元素,transform
用于对容器中的元素进行转换等。这些算法可以作用于不同的容器,只要容器的迭代器满足一定的要求。例如,std::vector<int> v = {3, 1, 2}; std::sort(v.begin(), v.end());
会对vector
中的元素进行排序。
迭代器(Iterators)
迭代器是一种类似于指针的对象,用于遍历容器中的元素。不同类型的容器有不同类型的迭代器。例如,vector
的迭代器支持随机访问,而list
的迭代器只支持顺序访问。可以使用迭代器来访问容器中的元素,修改元素,或者在容器中插入和删除元素。例如,vector<int>::iterator it = v.begin();
可以用于遍历vector
中的元素。
适配器(Adapters)
包括容器适配器(如stack
和queue
)和函数适配器。stack
和queue
是基于其他容器(如deque
或list
)实现的,它们提供了特定的接口,用于实现栈和队列的功能。函数适配器可以用于组合和修改函数对象,例如bind
函数可以用于绑定函数的参数。
空间配置器(Allocators)
用于管理容器的内存分配。默认的空间配置器使用new
和delete
来分配和回收内存。可以自定义空间配置器来满足特定的内存管理需求,例如使用内存池来提高内存分配的效率。
map 和 vector 在已知 key 和下标的查询复杂度
vector
对于vector
,当已知下标进行查询时,时间复杂度是 O (1)。这是因为vector
在内存中是连续存储的。例如,有一个vector<int> v = {1, 2, 3, 4, 5};
,如果要查询v[2]
,编译器会直接计算出元素的地址,地址计算公式为起始地址 + 下标 * 元素大小
。在这个例子中,假设v
的起始地址为addr
,int
类型大小为 4 字节,那么v[2]
的地址就是addr + 2 * 4
。这个计算过程非常快,不依赖于vector
的大小,所以时间复杂度是常数级别。
map
对于map
,当已知键(key)进行查询时,时间复杂度是 O (log n)。map
内部通常是用红黑树实现的。红黑树是一种自平衡二叉搜索树。例如,有一个map<int, string> m; m[1] = "one"; m[2] = "two"; m[3] = "three";
。当查询一个键(比如查询键为 2 的元素)时,它会从根节点开始,根据键的大小关系(小于、等于、大于)来遍历树的节点。每次比较都能排除树的一部分,经过大约 O (log n) 次比较就能找到目标节点或者确定目标节点不存在。这是因为红黑树的高度是 O (log n),其中 n 是树中的节点数量。所以,在map
中查询一个元素的时间复杂度和map
中元素的数量有关,但是对数级别的复杂度在实际应用中对于较大的数据量仍然是比较高效的。
c++sort 的实现方式
C++ 中的std::sort
是一个高效的排序算法,它是对多种排序算法的混合实现。
在大多数标准库的实现中,std::sort
采用了内省排序(Introspective Sort),它是一种混合排序算法,结合了快速排序(Quick Sort)、堆排序(Heap Sort)和插入排序(Insertion Sort)的优点。
-
快速排序部分
- 快速排序是一种分治算法。它首先选择一个 “枢轴”(pivot)元素,然后将数组分为两部分,一部分元素小于枢轴,另一部分元素大于枢轴。例如,对于数组
{5, 3, 8, 4, 2}
,如果选择 5 作为枢轴,经过划分后可能得到{3, 4, 2}
和{8}
。这个划分过程的时间复杂度在平均情况下是 O (n),但是在最坏情况下(例如数组已经有序)是 O (n²)。 - 在
std::sort
中,为了避免最坏情况的发生,它会采用一些策略。例如,它会选择合适的枢轴元素,通常是通过三数取中(Median - of - Three)的方法,即选择数组的第一个、中间一个和最后一个元素中的中位数作为枢轴,这样可以在一定程度上避免数组已经有序或者接近有序时的最坏情况。
- 快速排序是一种分治算法。它首先选择一个 “枢轴”(pivot)元素,然后将数组分为两部分,一部分元素小于枢轴,另一部分元素大于枢轴。例如,对于数组
-
堆排序部分
- 当快速排序的递归深度超过一定限制时(通常是和
log n
有关的深度),std::sort
会切换到堆排序。堆排序的时间复杂度是 O (n log n),并且它是一种稳定的排序算法。堆排序利用了二叉堆的数据结构,它可以在 O (n log n) 时间内将一个无序数组转换为有序数组。 - 例如,对于一个最小堆,堆顶元素是最小的元素。在排序过程中,通过不断地交换堆顶元素和最后一个元素,然后调整堆的结构,可以逐步将数组排序。
- 当快速排序的递归深度超过一定限制时(通常是和
-
插入排序部分
- 当子数组的大小变得足够小时(通常是一个较小的常数,如 10 - 20 个元素),
std::sort
会切换到插入排序。插入排序的时间复杂度在最坏情况下是 O (n²),但是对于接近有序的数组,它的性能很好,时间复杂度接近 O (n)。 - 插入排序的基本思想是将一个元素插入到已经有序的数组中。例如,对于数组
{2, 3, 5, 4}
,当插入 4 时,会从后往前比较,找到合适的位置插入 4,得到{2, 3, 4, 5}
。
- 当子数组的大小变得足够小时(通常是一个较小的常数,如 10 - 20 个元素),
这种混合排序算法使得std::sort
在各种情况下都能有较好的性能,无论是对于随机数组、接近有序的数组还是已经有序的数组。
C++ 程序的内存分布
C++ 程序的内存主要分为以下几个区域:
- 栈(Stack)
- 栈是一种后进先出(LIFO)的数据结构。在函数调用时,函数的局部变量(非静态局部变量)、函数参数等都会存储在栈中。例如,在一个函数
void func(int a, int b)
中,a
和b
这两个参数以及函数内部定义的局部变量都会被分配到栈上。 - 栈的内存是由编译器自动分配和释放的。当一个函数被调用时,栈帧(Stack Frame)会被创建,用于存储函数的局部信息。当函数返回时,栈帧会被销毁,栈上的内存会被自动释放。栈的大小是有限的,在不同的操作系统和编译器下,栈的大小可能不同。如果在栈上分配了过多的内存,可能会导致栈溢出(Stack Overflow)。
- 栈是一种后进先出(LIFO)的数据结构。在函数调用时,函数的局部变量(非静态局部变量)、函数参数等都会存储在栈中。例如,在一个函数
- 堆(Heap)
- 堆是用于动态分配内存的区域。通过
new
或malloc
等操作符可以在堆上分配内存。例如,int* ptr = new int;
会在堆上分配一个int
类型大小的内存块,并返回指向这个内存块的指针。 - 堆上的内存需要手动释放。对于
new
分配的内存,需要使用delete
来释放;对于malloc
分配的内存,需要使用free
来释放。如果没有正确地释放堆上的内存,可能会导致内存泄漏(Memory Leak),即内存被占用但无法再被使用。
- 堆是用于动态分配内存的区域。通过
- 全局 / 静态存储区(Global/Static Storage)
- 全局变量和静态变量(包括静态局部变量)存储在这个区域。例如,
int globalVar;
这个全局变量会存储在全局 / 静态存储区。 - 全局变量在程序的整个生命周期内都存在,它们在程序启动时被初始化,在程序结束时才被销毁。静态局部变量只在函数内部可见,但它的生命周期和全局变量一样长。这个区域的内存是在程序编译时就分配好的。
- 全局变量和静态变量(包括静态局部变量)存储在这个区域。例如,
- 常量存储区(Constant Storage)
- 用于存储常量数据,如字符串常量。例如,
const char* str = "Hello";
中的"Hello"
这个字符串常量就存储在常量存储区。 - 这个区域的数据在程序运行期间是只读的,不能被修改。如果试图修改常量存储区的数据,会导致程序运行时错误。
- 用于存储常量数据,如字符串常量。例如,
- 代码区(Code Segment)
- 存储程序的机器指令。这个区域是只读的,因为程序的代码在运行过程中通常不会被修改。当程序被加载到内存中时,可执行文件中的代码部分会被映射到代码区。例如,函数的实现代码、全局的模板函数等都会存储在代码区。
堆和栈的区别
堆和栈是计算机内存中的两个不同区域,它们有诸多区别。
从内存管理方式来讲,栈是由编译器自动管理的。当一个函数被调用时,函数所需的局部变量、参数等会在栈上分配空间。这些空间会在函数执行结束后自动释放,遵循后进先出(LIFO)的原则。例如,在一个函数中有多个局部变量的定义,它们在栈中的存储顺序是按照定义的先后顺序依次排列,当函数返回时,这些变量占用的空间会被依次回收。而堆是通过程序员手动分配和释放内存的。比如使用new
或者malloc
等函数在堆上开辟内存空间,之后需要用delete
或者free
来释放这些空间。如果忘记释放,就可能导致内存泄漏。
在内存分配的速度方面,栈的分配速度非常快。因为栈的内存管理简单,编译器可以预先计算出需要分配的空间大小,只需要移动栈指针就可以完成内存分配。而堆的分配速度相对较慢,堆内存分配需要在堆空间中查找合适大小的空闲块,这个过程可能涉及复杂的内存管理算法,比如链表遍历或者内存碎片整理等操作。
从内存空间的大小来看,栈的空间通常是有限的。它的大小在不同的系统和编译器下有所不同,一般在几兆字节左右。如果在栈上分配的空间超过了栈的最大容量,就会导致栈溢出。而堆的空间大小相对栈来说比较大,它受到计算机系统的虚拟内存大小的限制,在 32 位系统中,进程的虚拟内存空间一般是 4GB 左右,在 64 位系统中空间则更大。
从数据存储的内容上,栈主要存储函数调用的相关信息,包括函数参数、局部变量、返回地址等。堆则主要用于存储动态分配的数据,比如通过new
创建的对象或者用malloc
分配的数组等。
内存泄漏怎么办
当出现内存泄漏时,首先要做的是定位内存泄漏的位置。可以使用一些工具来帮助定位,比如在 Windows 系统下有专门的内存检测工具,如 Visual Studio 自带的诊断工具。在 Linux 系统下,valgrind
是一个强大的内存调试工具。
如果是在代码开发过程中发现内存泄漏,需要仔细检查代码中所有使用动态内存分配的地方。对于使用new
分配内存的情况,确保每一个new
都有对应的delete
。例如,在一个循环中动态分配了数组,要确保在循环结束或者合适的地方释放这些数组内存。如果是在类中分配了内存,可能需要在类的析构函数中释放这些内存。
对于一些复杂的情况,比如在多线程环境下,可能需要考虑线程同步和资源访问顺序。如果多个线程同时访问和分配内存,可能会导致内存泄漏或者其他内存相关的错误。此时,可以使用互斥锁等同步机制来确保在同一时间只有一个线程对内存进行分配和释放操作。
另外,合理的内存管理策略也可以预防内存泄漏。例如,采用智能指针代替原始的指针进行内存管理。智能指针能够自动释放所指向的对象,像std::unique_ptr
和std::shared_ptr
。std::unique_ptr
拥有对象的独占所有权,当unique_ptr
对象生命周期结束时,它所指向的对象会自动被释放。std::shared_ptr
通过引用计数的方式来管理对象,当引用计数为 0 时,对象也会被释放,这样可以有效避免因为忘记释放内存而导致的内存泄漏。
智能指针的种类
C++ 中有几种主要的智能指针。
首先是std::unique_ptr
。它用于对对象进行独占式的所有权管理。这意味着一个对象只能有一个unique_ptr
与之关联。例如,在自定义的资源管理类中,资源的所有权可以通过unique_ptr
来明确。当unique_ptr
被销毁时,它所指向的对象也会被自动销毁。unique_ptr
不允许复制,这是为了保证独占性。但是可以通过std::move
函数来转移所有权。比如,有一个unique_ptr<int> ptr1 = std::make_unique<int>(42);
,可以通过std::unique_ptr<int> ptr2 = std::move(ptr1);
将ptr1
的所有权转移给ptr2
,之后ptr1
就不再拥有对原来对象的所有权,而ptr2
成为新的所有者。这种机制在管理独占资源时非常有用,比如文件句柄、数据库连接等资源的管理。
其次是std::shared_ptr
。它是基于引用计数的智能指针。多个shared_ptr
可以同时指向同一个对象。例如,有一个std::shared_ptr<int> ptr3 = std::make_shared<int>(10);
,可以再创建一个std::shared_ptr<int> ptr4 = ptr3;
,此时ptr3
和ptr4
都指向同一个int
对象,并且这个对象的引用计数为 2。每当有一个新的shared_ptr
指向这个对象时,引用计数就会加 1,当一个shared_ptr
不再指向这个对象(比如超出了作用域或者被重新赋值)时,引用计数就会减 1。当引用计数为 0 时,对象就会被自动销毁。shared_ptr
在多个对象需要共享同一份资源的场景下很有用,比如在多线程环境下共享的数据结构,或者在对象之间存在复杂的引用关系的情况下。
还有std::weak_ptr
。它主要是为了解决shared_ptr
可能出现的循环引用问题。weak_ptr
不控制对象的生命周期,它只是对shared_ptr
所管理的对象进行一种弱引用。例如,在两个相互引用的类中,如果都使用shared_ptr
来引用对方,就可能会出现循环引用导致内存无法释放的情况。通过使用weak_ptr
,可以避免这种情况。weak_ptr
可以通过lock
方法来获取一个shared_ptr
,如果对象还存在,就可以通过获取到的shared_ptr
来访问对象,否则lock
方法会返回一个空的shared_ptr
。
循环引用计数的最终值
在存在循环引用的情况下,如果仅仅使用shared_ptr
,引用计数的最终值可能不会降为 0。
例如,假设有两个类A
和B
,它们互相包含shared_ptr
成员。当创建A
和B
的对象并且互相引用时,它们的引用计数会因为互相指向而无法降为 0。具体来说,当创建一个A
类的对象a
和一个B
类的对象b
,a
中有一个shared_ptr
指向b
,b
中也有一个shared_ptr
指向a
。此时,a
的引用计数至少为 1(因为b
中有一个shared_ptr
指向它),b
的引用计数也至少为 1(因为a
中有一个shared_ptr
指向它)。
即使这两个对象超出了它们原本应该释放的作用域,它们的引用计数依然不会为 0,因为它们之间的循环引用使得它们互相 “维持生命”。这就会导致内存泄漏,因为这些对象占用的内存无法被释放。
为了解决这个问题,可以引入weak_ptr
。weak_ptr
不会增加引用计数。当使用weak_ptr
来打破这种循环引用时,对象的引用计数就可以正常地增减。例如,将A
类中的shared_ptr
成员改为weak_ptr
成员,这样B
对象对A
对象的引用就不会增加A
对象的引用计数。当B
对象的shared_ptr
被销毁(比如超出作用域)时,A
对象的引用计数可以正常地减为 0,从而使得A
对象可以被正常释放。当A
对象被释放后,B
对象中原本通过weak_ptr
关联的A
对象就不存在了,B
对象的引用计数也可以正常地减为 0,进而B
对象也可以被释放。
shared_ptr 是否线程安全
std::shared_ptr
在引用计数的操作上是线程安全的。这意味着当多个线程同时对shared_ptr
进行拷贝构造、赋值或者析构操作时,C++ 标准库保证了引用计数的正确性。
例如,在一个多线程环境中,有多个线程同时访问一个shared_ptr
对象。当一个线程对这个shared_ptr
进行拷贝操作(比如创建一个新的shared_ptr
指向同一个对象)时,引用计数会在一个原子操作下加 1。同样,当一个线程中的shared_ptr
对象生命周期结束或者被重新赋值时,引用计数会在原子操作下减 1。
然而,shared_ptr
所指向的对象本身不是线程安全的。如果多个线程通过shared_ptr
访问和修改对象的非原子成员变量,就可能会导致数据竞争和未定义行为。
例如,假设有一个shared_ptr
指向一个包含共享资源(如一个计数器)的对象。多个线程通过shared_ptr
访问并修改这个计数器成员,这会导致数据竞争。为了避免这种情况,可以使用互斥锁等同步机制来保护对共享对象的访问。比如,在对象的成员函数中使用std::mutex
来对共享资源的访问进行加锁和解锁操作,确保在同一时间只有一个线程可以修改共享资源。
模板的特化和偏特化
模板特化是指为特定类型或特定条件下的模板参数提供专门的实现。当编译器遇到模板实例化时,如果有特化版本,就会使用特化版本而不是通用模板。例如,对于一个模板函数template<typename T> void print(T value)
,如果对int
类型进行特化,可以写成template<> void print(int value)
,在这个特化版本中,可以针对int
类型进行特殊的输出处理。
偏特化则是对模板参数的部分进行特化。比如对于一个类模板template<typename T1, typename T2> class MyClass
,可以对其中一个类型参数进行偏特化。假设偏特化T1
为int
,可以写成template<typename T2> class MyClass<int, T2>
。偏特化在处理具有特定模式的模板参数组合时非常有用。它允许在不完全指定所有模板参数的情况下,为一组相关类型提供特殊实现。在模板偏特化中,可以根据特定的类型特征或类型关系来定制类或函数的行为。例如在处理容器模板时,可能对指针类型的元素有特殊的处理逻辑,通过偏特化可以轻松实现。这样可以提高代码的复用性和灵活性,使模板能够更好地适应不同类型的输入,同时在需要特殊处理的情况下提供针对性的解决方案,避免了为每种可能的类型组合都编写全新的模板或函数。而且在编译时,编译器能够根据传入的模板参数准确地选择合适的特化或偏特化版本,提高了程序的效率和可维护性。
C++ 和 C 申请内存方式的区别
在 C 语言中,主要的内存申请函数是malloc
、calloc
和realloc
。malloc
用于分配指定字节数的内存块,它只负责分配内存,不会对内存进行初始化。例如int *ptr = (int *)malloc(sizeof(int));
,这里只是分配了足够存储一个int
类型的内存空间。calloc
则会在分配内存的同时将内存初始化为 0,如int *arr = (int *)calloc(10, sizeof(int));
,它分配了能存储 10 个int
类型的内存空间且初始化为 0。realloc
用于重新分配内存大小。
而在 C++ 中,除了可以使用new
和delete
运算符外,也可以使用 C 语言的内存申请函数。new
运算符有多种形式。对于单个对象,int *p = new int;
不仅分配了内存,还会调用int
类型的默认构造函数(如果有)。对于数组,int *arr = new int[10];
会分配能存储 10 个int
类型的连续内存空间,并对每个int
进行默认初始化。new
和malloc
等函数的一个重要区别是new
与类型系统结合更紧密,它知道所分配对象的类型,能自动调用构造函数。而且new
返回的是指向对象类型的指针,而malloc
返回的是void *
类型的指针,需要进行强制类型转换。另外,delete
用于释放new
分配的内存,对于单个对象使用delete
,对于数组使用delete[]
,这和 C 语言中free
函数释放malloc
等分配的内存有所不同,free
函数不区分是单个对象还是数组的内存。
C++ 释放数组和普通对象的区别
在 C++ 中,使用new
分配内存时,释放方式对于数组和普通对象是不同的。
当使用new
创建一个普通对象时,比如int *p = new int;
,释放这个对象所占用的内存应该使用delete
。这是因为new
操作在分配内存的同时,如果对象有构造函数会调用构造函数进行初始化,delete
操作则会先调用对象的析构函数(如果有)然后释放内存。
然而,当使用new
创建一个数组时,例如int *arr = new int[10];
,释放内存必须使用delete[]
。这是因为new[]
分配数组内存时,会记录数组的大小等额外信息,以便在delete[]
时正确地释放整个数组的内存。如果错误地使用delete
来释放数组内存,可能会导致内存泄漏或者程序崩溃。因为delete
只会释放单个对象的内存,对于数组,它可能只释放了数组的第一个元素所占用的内存,而剩余的内存没有被释放,同时还可能破坏了内存管理的相关数据结构。另外,如果是自定义类型的数组,使用delete
而不是delete[]
可能不会正确地调用每个元素的析构函数,这也会导致资源没有被正确清理。
动态多态虚表的位置
在具有动态多态性的 C++ 类体系中,虚表通常位于对象内存布局的开头部分(对于单继承情况)。
当一个类包含虚函数时,编译器会为这个类创建一个虚表。虚表本质上是一个函数指针数组,数组中的每个元素指向一个虚函数的实现。对于每个包含虚函数的类的对象,对象的内存起始处会包含一个指向虚表的指针(vptr)。这个指针的大小通常与指针类型的大小相同(在 32 位系统中是 4 字节,在 64 位系统中是 8 字节)。
在多继承的情况下,情况会更复杂。每个基类的虚表指针都会在派生类对象中存在。如果有多个基类都有虚函数,派生类对象会包含多个虚表指针,它们的位置和顺序与继承的顺序相关。而且,在这种情况下,虚函数的调用可能会涉及到更复杂的偏移计算,以确保能够正确地在虚表中找到相应的虚函数指针。
这种虚表机制是实现动态多态的关键。当通过基类指针或引用调用虚函数时,程序会根据对象的实际类型(通过虚表指针找到对应的虚表)来确定要调用的虚函数版本。这使得在运行时可以根据对象的实际类型做出不同的行为,实现了多态性。例如,在一个图形类层次结构中,有基类Shape
和派生类Circle
、Rectangle
等,每个类都有draw
这个虚函数。通过Shape *shapePtr = new Circle();
创建一个Circle
对象并通过基类指针调用draw
函数时,程序会根据Circle
对象中的虚表指针找到Circle
类的虚表,从而调用Circle
类的draw
函数实现,而不是Shape
类的draw
函数实现。
有序数组去重不用额外空间
对于有序数组去重且不使用额外空间的问题,可以利用有序数组的特性来解决。
由于数组是有序的,相同的元素会相邻。可以使用两个指针,一个慢指针和一个快指针。慢指针用于标记已经处理好的不重复元素的位置,快指针用于遍历数组。
开始时,慢指针和快指针都指向数组的开头。当快指针开始移动时,它会逐个检查元素。如果快指针所指的元素和慢指针所指的元素不相等,这意味着遇到了一个新的不重复元素。此时,将慢指针向后移动一位,并将快指针所指的元素复制到慢指针所指的位置。然后快指针继续向后移动。如果快指针所指的元素和慢指针所指的元素相等,说明是重复元素,快指针继续向后移动,而不进行复制操作。
例如,对于有序数组{1, 1, 2, 2, 3, 4, 4}
,开始时慢指针和快指针都指向第一个元素 1。当快指针移动到第二个 1 时,因为它和慢指针所指元素相等,快指针继续移动。当快指针移动到 2 时,和慢指针所指元素不同,将慢指针向后移动一位,并将 2 复制到慢指针位置,此时数组变为{1, 2, 2, 2, 3, 4, 4}
,然后快指针继续移动,重复这个过程。最终,数组会变为{1, 2, 3, 4}
,实现了去重。这种方法不需要额外的空间,只是在原数组上通过指针操作来完成去重,利用了有序数组中相同元素相邻的特点,提高了空间效率。
二叉树度为 0 和度为 2 的数量关系
在二叉树中,度为 0 的节点(叶子节点)和度为 2 的节点存在特定关系。设二叉树中度为 0 的节点个数为 n0,度为 1 的节点个数为 n1,度为 2 的节点个数为 n2。根据二叉树的性质,二叉树的节点总数 N = n0 + n1 + n2。同时,二叉树中边的总数 E = N - 1(因为除了根节点,每个节点都有一条入边),而边的总数又可以表示为 E = n1 + 2 * n2(度为 1 的节点有 1 条边,度为 2 的节点有 2 条边)。由此可得 n0 + n1 + n2 - 1 = n1 + 2 * n2,化简后得到 n0 = n2 + 1。这意味着在二叉树中,度为 0 的节点个数总是比度为 2 的节点个数多 1。例如,当度为 2 的节点有 3 个时,度为 0 的节点必然有 4 个。这个关系在分析二叉树的结构和相关算法时非常重要,比如在计算二叉树的高度、遍历二叉树等操作中,可以根据叶子节点的情况来推断度为 2 的节点数量,进而更好地理解二叉树的整体特性。
哈夫曼树构建过程
哈夫曼树是一种带权路径长度最短的二叉树。构建哈夫曼树的步骤如下。首先,有一组给定的权值,将每个权值看作一个独立的节点,形成一个森林。这些节点都是二叉树的根节点,且每个二叉树只有一个节点。然后,在森林中选择权值最小的两个节点,将它们作为左右子节点构建一个新的二叉树,新二叉树的根节点权值为这两个子节点权值之和。接着,将这个新的二叉树放回森林中。不断重复这个过程,每次都选择权值最小的两个树合并,直到森林中只剩下一棵树,这棵树就是哈夫曼树。例如,有节点权值分别为 2、3、5、7。首先选择 2 和 3 合并,得到一个根节点权值为 5 的新二叉树,森林中剩下权值为 5、7 和新的权值为 5 的树。再选择两个权值为 5 的树合并,得到根节点权值为 10 的树,森林中剩下权值为 7 和 10 的树。最后将 7 和 10 合并,得到哈夫曼树。在构建过程中,权值较小的节点通常在树的较底层,这样能保证带权路径长度最短,这种树在数据压缩编码等领域有广泛应用。
快排最坏情况发生的条件
快速排序的最坏情况发生在每次划分选取的枢轴元素都是当前子数组中最大或最小元素的时候。例如,当对一个已经有序的数组进行快速排序时,如果每次都选择第一个元素作为枢轴,就会出现这种情况。对于一个升序排列的数组,第一次划分后,枢轴左边没有元素,右边是除枢轴外的所有元素,子问题规模只减少了 1。然后对右边的子数组继续划分,每次都选择最左边的元素作为枢轴,这样会导致划分次数达到最大值,时间复杂度从平均的 O (n log n) 退化为 O (n²)。同样,对于降序排列的数组,每次选择最左边元素作为枢轴也会出现最坏情况。另外,如果数组中的元素全部相等,无论选择哪个元素作为枢轴,也会导致最坏情况,因为每次划分都会得到一个空的子数组和一个包含所有剩余元素的子数组,使得划分次数达到了 n - 1 次,总共的比较次数接近 n²/2,大大增加了排序的时间成本。
递归算法和循环算法的对比
递归算法是指在函数的定义中使用函数自身的方法。它的优点在于代码简洁、直观,能够清晰地表达问题的求解逻辑。例如,在计算阶乘时,使用递归算法可以写成int factorial(int n) { if (n == 0 || n == 1) return 1; return n * factorial(n - 1); }
,这种方式很容易理解,因为它直接反映了阶乘的数学定义。递归算法在处理具有递归结构的数据结构(如树)时非常方便,比如二叉树的前序遍历,void preorderTraversal(TreeNode* root) { if (root) { cout << root->val << " "; preorderTraversal(root->left); preorderTraversal(root->right); } }
,代码结构与二叉树的递归定义相匹配。
然而,递归算法也有缺点。递归调用会在栈上创建新的栈帧来保存函数的局部变量和返回地址等信息。如果递归深度太深,可能会导致栈溢出。例如,在计算一个非常大的数的阶乘时,可能会耗尽栈空间。而且递归算法在某些情况下可能存在重复计算,降低了效率。
循环算法则是通过迭代的方式来解决问题。它通常使用循环结构(如for
、while
)。循环算法的优点是效率高,不会像递归那样存在栈溢出的风险,因为它不需要在栈上保存大量的函数调用信息。例如,使用循环计算阶乘可以写成int factorial(int n) { int result = 1; for (int i = 1; i <= n; i++) { result *= i; } return result; }
。但是,对于一些复杂的问题,尤其是具有递归性质的问题,循环算法的代码可能会变得复杂和难以理解,不像递归算法那样能够直接反映问题的本质。
优先队列的实现
优先队列是一种特殊的队列,其中每个元素都有一个优先级,出队操作总是移除具有最高(或最低)优先级的元素。
一种常见的实现方式是使用二叉堆。二叉堆可以是最小堆或最大堆。在最小堆中,父节点的值小于或等于子节点的值;在最大堆中,父节点的值大于或等于子节点的值。
对于插入操作,当向堆中插入一个新元素时,先将元素插入到堆的末尾,然后通过向上调整(也称为上浮操作)来维护堆的性质。例如,在最小堆中,如果新插入的元素比它的父节点小,就交换它们的位置,一直重复这个过程直到满足堆的性质。
删除操作(以删除堆顶元素为例),首先将堆顶元素和堆的最后一个元素交换,然后删除最后一个元素。之后,对新的堆顶元素进行向下调整(也称为下沉操作)。在最小堆中,如果堆顶元素大于它的某个子节点,就将它与较小的子节点交换,一直重复这个过程直到满足堆的性质。
另一种实现方式是使用二叉搜索树。二叉搜索树的每个节点都有一个键值,左子树中的键值都小于根节点的键值,右子树中的键值都大于根节点的键值。在优先队列中,可以根据元素的优先级作为键值来构建二叉搜索树。插入操作就是在二叉搜索树中插入新节点,删除操作可以是删除具有最高(或最低)优先级的节点(比如最小键值的节点)。不过,使用二叉搜索树实现的优先队列在最坏情况下的时间复杂度可能不如二叉堆,因为二叉搜索树可能会退化为链表,而二叉堆的插入和删除操作在平均和最坏情况下都能保证较好的时间复杂度。此外,还有一些基于数组、链表等数据结构的其他实现方式,但它们的性能和复杂度特点各不相同。
超大文件无法一次性加载到内存如何排序
当面对超大文件无法一次性加载到内存进行排序时,可以采用外部排序的方法。一种常见的策略是归并排序的外部实现。
首先,将大文件分割成多个小的、可以加载到内存的块。例如,根据内存的大小,把文件分成若干个大小合适的子文件,每个子文件的大小小于内存容量。然后,将这些子文件分别加载到内存中,使用内部排序算法(如快速排序、堆排序等)对每个子文件进行排序。
在完成子文件的排序后,通过归并操作来合并这些已经排序好的子文件。可以使用一个较小的缓冲区来实现归并。每次从各个已排序的子文件中读取一部分数据到缓冲区,然后按照归并排序的规则,将最小(或最大)的数据写入到一个新的文件中。这个过程不断重复,逐步将所有子文件归并成一个排序好的大文件。
还可以采用多路归并的方式,同时对多个子文件进行归并。不过,这需要更复杂的缓冲区管理和数据读取策略。例如,使用一个优先队列来记录每个子文件当前要读取的最小元素,每次从优先队列中取出最小的元素,然后从对应的子文件中读取下一个元素加入优先队列。
另外,也可以利用分布式计算的思想。如果有多台机器或者多个处理器,可以将文件分割后分配到不同的计算单元进行排序,然后再将结果合并。这种方法可以大大提高排序的效率,但需要考虑数据传输和同步等问题。
B + 树与普通树、红黑树的区别及不用 B 树的原因
B + 树是一种平衡的多路查找树。与普通树相比,B + 树的节点可以有多个子节点,普通树通常是二叉树,每个节点最多有两个子节点。B + 树的这种多路特性使得它在存储大量数据时,树的高度相对较低。例如,在存储相同数量的数据时,二叉树可能需要很高的高度,而 B + 树可以通过增加每个节点的子节点数量来降低高度。
与红黑树相比,B + 树更适合用于磁盘等外部存储设备的数据存储和检索。红黑树主要用于内存中的数据结构,它的平衡操作是为了保证树的高度在对数级别,以实现高效的插入、删除和查找操作,时间复杂度一般为 O (log n)。B + 树的叶子节点形成一个有序链表,这使得范围查询非常方便。在 B + 树中,所有的数据都存储在叶子节点,非叶子节点只用于索引,这样可以使树更加紧凑,提高存储效率。
至于为什么有时候不使用 B 树,B 树每个节点都存储数据,这使得节点的大小可能会受到限制,因为节点过大可能无法一次加载到内存。而且 B 树的范围查询没有 B + 树方便,因为 B 树的数据分布在各个节点,不是像 B + 树那样集中在叶子节点。在数据库索引等应用场景中,B + 树的优势更加明显,它能够有效地减少磁盘 I/O 操作,提高数据查询的效率。
HTTP 1/2/3 版本的区别
HTTP 1.0 是早期的版本,它的主要特点是简单直接。它每次请求 - 响应都需要建立一个新的 TCP 连接,这样会导致性能问题,因为建立 TCP 连接需要进行三次握手,这是比较耗时的。而且它没有对头部信息进行优化,随着网页内容变得复杂,头部信息可能会变得很长,增加了传输成本。
HTTP 1.1 对 1.0 进行了改进。它支持持久连接,即一个 TCP 连接可以用于多个请求 - 响应,减少了建立连接的次数。同时,它引入了管道机制,允许客户端在一个连接中发送多个请求,而不用等待每个请求的响应,提高了性能。但是,管道机制也存在一些问题,如队首阻塞,当第一个请求的响应被阻塞时,后面的请求也无法得到响应。
HTTP 2.0 在性能上有了更大的提升。它采用二进制分帧层,将 HTTP 消息分解为更小的帧进行传输,这样可以在一个 TCP 连接上同时发送多个请求和响应的帧,避免了队首阻塞。它还对头部信息进行了压缩,通过 HPACK 算法减少了头部信息的大小,进一步提高了传输效率。另外,它支持服务器推送,服务器可以主动向客户端推送一些可能会用到的资源,如样式表、脚本等,减少了客户端再次请求的时间。
HTTP 3.0 则是基于 UDP 协议的。因为 HTTP 2.0 虽然性能有很大提升,但仍然基于 TCP,TCP 在一些网络环境下可能会受到限制,如在高丢包率的网络中,TCP 的重传机制会导致性能下降。HTTP 3.0 使用 QUIC 协议,它在 UDP 基础上实现了类似 TCP 的功能,如可靠传输、拥塞控制等,同时继承了 UDP 的低延迟特性,并且进一步优化了连接建立过程,减少了连接建立的时间。
HTTP Cookie 的作用
HTTP Cookie 是服务器发送给浏览器的一小段信息,浏览器会将其保存起来。
一个重要的作用是用于用户身份识别。例如,在用户登录网站后,服务器会在响应中发送一个包含用户身份标识的 Cookie。之后,当用户再次访问该网站的其他页面时,浏览器会将这个 Cookie 发送给服务器,服务器通过识别 Cookie 中的信息,就可以知道是哪个用户在访问,从而提供个性化的服务,如显示用户的个人信息、购物车内容等。
Cookie 还可以用于记录用户的浏览状态。比如,在一个多步骤的表单填写过程中,服务器可以通过 Cookie 来记录用户已经填写的步骤,这样即使用户在填写过程中离开页面,再次回来时也可以继续之前的步骤。
另外,Cookie 在广告追踪方面也有应用。广告商可以通过在不同网站设置 Cookie 来追踪用户的浏览行为,了解用户的兴趣爱好,从而推送更有针对性的广告。不过,这种广告追踪功能也引发了隐私问题,因为用户的浏览行为可能会被过度收集和利用。
在会话管理方面,Cookie 可以用于维护会话。服务器可以通过设置一个会话相关的 Cookie,来跟踪用户的会话状态,如判断用户的会话是否已经过期等。这样可以确保网站服务的连贯性和安全性,例如,在一些需要用户登录才能访问的网站功能中,通过 Cookie 来管理会话可以防止未经授权的访问。
TCP 拥塞控制方法
TCP 拥塞控制主要是为了避免网络出现拥塞,确保网络的高效和稳定运行。
一种常见的方法是慢启动。在连接建立初期,TCP 发送方会从一个较小的拥塞窗口(cwnd)开始发送数据,通常初始拥塞窗口大小为 1 个 MSS(最大报文段长度)。然后,每收到一个对新发送数据的确认(ACK),拥塞窗口就会加倍。例如,发送方发送了 1 个 MSS 的数据,收到 ACK 后,拥塞窗口变为 2 个 MSS,下次就可以发送 2 个 MSS 的数据。这样可以快速探测网络的可用带宽,但是如果增长速度过快,可能会导致网络拥塞。
当拥塞窗口增长到一个阈值(ssthresh)时,就会进入拥塞避免阶段。在这个阶段,拥塞窗口不再是加倍增长,而是线性增长。例如,每收到一个 ACK,拥塞窗口增加 1 个 MSS。这种方式可以更加谨慎地利用网络带宽,避免过度占用网络资源。
当检测到网络拥塞时,例如收到三个重复的 ACK 或者发生超时,TCP 会采取拥塞控制措施。如果是收到三个重复的 ACK,这被称为快速重传,发送方会立即重传丢失的数据段,然后将拥塞窗口减半,同时将 ssthresh 设置为当前拥塞窗口大小。如果是发生超时,发送方会将拥塞窗口设置为 1 个 MSS,同时将 ssthresh 设置为原来拥塞窗口大小的一半,然后重新进入慢启动阶段。
另外,还有一些改进的拥塞控制算法,如 TCP Reno、TCP NewReno 等。TCP Reno 在快速重传后,会进入快速恢复阶段,在这个阶段,拥塞窗口不是像传统方法那样直接减半,而是根据一定的规则进行调整,以更快地恢复数据发送,减少网络拥塞对数据传输的影响。
epoll、select、poll 的优缺点、底层、应用场景、连接数限制和效率对比
一、select
底层机制:select 通过一个 fd_set 结构体来存储要监视的文件描述符集合。在调用 select 时,内核会遍历这个集合,检查每个文件描述符的状态。
优点:
- 跨平台性好,在很多操作系统上都能使用。
- 接口简单,易于理解和使用。
缺点:
- 单个进程可监视的文件描述符数量有限,通常由 FD_SETSIZE 限制,这个限制在不同系统可能比较小,比如在 Linux 下一般是 1024。
- 每次调用 select 都需要把 fd_set 从用户空间拷贝到内核空间,并且在返回时还要从内核空间拷贝回用户空间。当监视的文件描述符较多时,这会带来较大的开销。
- 采用轮询的方式检查文件描述符状态,效率较低,即使只有少量文件描述符就绪,也要遍历整个集合。
应用场景:适用于连接数较少且对性能要求不是特别高的场景,比如简单的小型网络服务程序。
效率:在连接数较多时效率较低,因为轮询和频繁的数据拷贝会消耗大量时间。
二、poll
底层机制:poll 使用一个 pollfd 结构数组来存储要监视的文件描述符及其事件。内核会遍历这个数组来检查文件描述符状态。
优点:
- 没有像 select 那样的文件描述符数量限制,它是基于链表存储的,可以监视更多的文件描述符。
- 接口相对简单,和 select 类似,容易上手。
缺点:
- 同样需要将大量的 pollfd 结构从用户空间拷贝到内核空间,并且在返回时拷贝回来,这在监视大量文件描述符时开销较大。
- 也是采用轮询的方式,效率会随着监视文件描述符数量的增加而降低。
应用场景:适用于需要监视较多文件描述符,且对性能要求稍高一些,但对延迟不太敏感的场景,比如一些中大型网络服务。
效率:比 select 在处理较多文件描述符时稍有优势,但随着文件描述符增多,效率下降仍然明显。
三、epoll
底层机制:epoll 是基于事件驱动的。它在内核中有一个红黑树来管理所有要监视的文件描述符,还有一个就绪列表来存储已经就绪的文件描述符。当文件描述符状态发生变化时,内核会通过回调函数将其加入就绪列表。
优点:
- 没有文件描述符数量的硬限制,理论上可以监视大量的文件描述符。
- 采用事件驱动机制,只有当文件描述符就绪时才会通知应用程序,避免了像 select 和 poll 那样的轮询,大大提高了效率。
- 内核和用户空间通过共享内存来传递就绪文件描述符信息,减少了数据拷贝的开销。
缺点:
- 只在 Linux 系统下有效,跨平台性差。
- 虽然效率高,但代码实现相对复杂一些。
应用场景:适用于高并发的网络服务器,如大型的 Web 服务器、消息队列服务器等,对高性能和大量连接处理有要求的场景。
效率:在处理大量连接和高并发场景下效率远远高于 select 和 poll,能够有效利用系统资源,减少 CPU 的占用。
mysql 回表的含义
在 MySQL 中,回表是在使用索引进行查询时可能出现的一种情况。
MySQL 的索引存储了索引列的值和对应的主键(如果是二级索引)。当我们通过索引进行查询,例如使用一个二级索引查找满足某个条件的数据时,索引会先被扫描。如果查询的列都包含在索引中,那么可以直接从索引中获取数据,这是比较高效的情况。
然而,如果查询的列有部分不在索引中,那么在通过索引找到对应的主键后,还需要根据主键去聚集索引(对于 InnoDB 存储引擎)或者数据文件(对于其他存储引擎)中查找完整的数据记录,这个过程就叫做回表。
例如,有一个表包含 id(主键)、name 和 age 列,并且在 name 列上建立了一个二级索引。当执行一个查询,如 “SELECT age FROM table WHERE name = 'John'”,首先会在 name 的二级索引中查找名字为 'John' 的记录,这个二级索引中存储了 name 列的值和对应的 id。找到 id 后,因为要查询的 age 列不在 name 的二级索引中,所以需要通过 id 回到聚集索引(也就是回表)去查找完整的数据记录,以获取 age 列的值。
回表操作会增加查询的时间成本,因为它涉及到额外的磁盘 I/O 或者内存访问。为了减少回表操作,可以尽量让查询的列都包含在索引中,例如通过创建包含多个列的联合索引来优化查询。
mysql 和 redis 一致性
MySQL 是一个关系型数据库,而 Redis 是一个高性能的键 - 值存储系统。
在实际应用中,保持它们之间的一致性是一个重要的问题。
当数据同时存在于 MySQL 和 Redis 中时,例如,在一个电商系统中,商品库存信息存储在 MySQL 中,同时为了快速查询也缓存到 Redis 中。
一种情况是更新 MySQL 后更新 Redis。如果先更新 MySQL,在更新 Redis 的过程中出现故障,可能会导致 Redis 中的数据与 MySQL 不一致。为了避免这种情况,可以采用事务或者消息队列来确保 Redis 的更新也能成功。例如,将更新 Redis 的操作放入消息队列,由消费者来执行更新操作,这样即使出现短暂故障,也可以在系统恢复后通过消息队列来完成更新。
另一种情况是先更新 Redis,再更新 MySQL。同样可能会出现更新 MySQL 失败的情况,导致数据不一致。可以采用两阶段提交或者分布式事务来解决这个问题。
在数据读取方面,如果 Redis 中的数据过期或者被错误地修改,可能会读取到不一致的数据。可以通过设置合理的过期时间、数据验证机制等来确保读取的数据是准确的。
另外,还可以采用读写分离、主从复制等策略来优化数据的读取和写入,同时要注意这些策略可能会对一致性产生影响。例如,在主从复制的 MySQL 架构中,如果主从同步出现延迟,可能会导致读取到的数据不一致,对于这种情况可以通过监控主从同步状态、采用半同步复制等技术来减少延迟和不一致的风险。
kafka 如何保证订单业务消息被服务端顺序消费
在 Kafka 中,要保证订单业务消息被服务端顺序消费,主要通过以下几种方式。
首先,Kafka 的分区(Partition)机制对于顺序性有重要作用。一个主题(Topic)可以有多个分区,消息在写入 Kafka 时会根据一定的规则(如哈希算法)被分配到不同的分区。如果要保证订单业务消息的顺序消费,那么可以将属于同一个订单的消息发送到同一个分区。因为在一个分区内,消息是按照写入的顺序存储和读取的,这样消费者按照分区的顺序进行消费,就可以保证同一订单的消息顺序。
其次,消费者组(Consumer Group)的设置也很关键。在一个消费者组中,每个分区只能被一个消费者消费。这样可以避免多个消费者对同一分区的消息进行乱序消费。例如,一个消费者组中有多个消费者,当一个分区的消息被分配给其中一个消费者后,其他消费者不会再处理这个分区的消息,从而保证了在分区内消息的顺序消费。
另外,在消费者端,需要确保消费者是单线程或者采用有序的多线程处理方式来消费分区内的消息。如果是多线程消费,需要对消息进行合理的排序和分配,以避免出现顺序错乱的情况。例如,可以采用一个消息队列来缓冲从 Kafka 分区读取的消息,然后按照顺序将消息分配给不同的线程进行处理。
同时,Kafka 本身的可靠性机制也有助于保证消息顺序。例如,消息在写入分区后会被持久化存储,并且可以设置消息的复制因子,确保消息在多个副本之间的一致性。这样,即使某个副本出现故障,也可以从其他副本中获取消息,并且保证消息的顺序不受影响。
nginx 负载均衡的实现方式和 nginx 子请求
一、nginx 负载均衡实现方式
轮询(Round - Robin):这是一种最简单的负载均衡方式。nginx 会按照顺序依次将请求分配到后端的服务器组中。例如,有服务器 A、B、C,第一个请求分配到 A,第二个请求分配到 B,第三个请求分配到 C,然后再从 A 开始循环。这种方式简单公平,适用于服务器性能相近的情况。
加权轮询(Weighted Round - Robin):考虑到后端服务器的性能可能不同,加权轮询可以为不同的服务器分配不同的权重。例如,服务器 A 性能较强,权重设为 3,服务器 B 和 C 性能稍弱,权重设为 1。那么在分配请求时,会按照权重的比例来分配。可能会出现 A 接收 3 个请求,B 接收 1 个请求,C 接收 1 个请求这样的分配方式,使得性能强的服务器能够处理更多的请求。
IP 哈希(IP Hash):根据客户端的 IP 地址进行哈希计算,将请求固定分配到某一台后端服务器。这样可以保证来自同一个 IP 的请求总是被分配到同一台服务器,适用于需要保持会话状态的场景。例如,在一个有用户登录的 Web 应用中,通过 IP 哈希可以确保用户的多次请求都被发送到同一台服务器,避免用户在不同服务器之间切换导致的会话丢失。
URL 哈希(URL Hash):根据请求的 URL 进行哈希计算,将具有相同 URL 的请求分配到同一台后端服务器。这种方式适用于对特定 URL 的请求进行优化处理的场景,比如对于某些频繁访问的静态资源,可以固定分配到某一台性能较好的服务器。
二、nginx 子请求
nginx 子请求是一种特殊的请求方式。当主请求进入 nginx 后,nginx 可以发起一个或多个子请求。子请求可以看作是在主请求的上下文中发起的额外请求。
子请求的 URL 可以是内部的,也可以是外部的。内部子请求主要用于在 nginx 内部进行数据的组合或者预处理。例如,在一个 Web 应用中,可以使用子请求来获取不同的部分数据,然后将这些数据组合起来返回给客户端。
子请求的执行是异步的,它不会阻塞主请求的处理。在子请求处理过程中,主请求可以继续进行其他操作。当子请求完成后,其结果可以被主请求使用。例如,在一个页面需要同时获取用户信息和商品信息时,nginx 可以发起两个子请求分别获取这两种信息,然后将它们组合起来作为主请求的响应返回给客户端。这种方式可以提高服务器的响应速度和处理效率。
前端虚拟路径访问后端服务器路径时如何屏蔽部分内容及配置名称
在前端访问后端服务器路径时,为了屏蔽部分内容,通常会涉及到路径代理和配置相关操作。
在开发环境中,以 Webpack 为例,它提供了一种代理配置机制。可以在 Webpack 的配置文件中设置devServer.proxy
选项。这个选项允许将前端发送的请求代理到不同的后端服务器路径,并且可以对路径进行修改和屏蔽部分内容。比如,如果前端应用通过/api
虚拟路径访问后端服务,而实际后端服务的接口路径是http://backend-server/api/v1
,可以通过代理配置将/api
映射到http://backend-server/api/v1
。在配置过程中,可以通过正则表达式或者字符串匹配的方式,对路径中的某些部分进行替换或者屏蔽。例如,对于请求路径/api/user/info
,可以将/api
部分替换为实际的后端服务器地址,同时屏蔽掉前端不需要的部分路径,使得请求能够正确地发送到后端服务器的http://backend-server/api/v1/user/info
路径。
在生产环境中,通常会使用 Nginx 作为反向代理服务器。在 Nginx 的配置文件中,可以通过location
指令来处理前端请求的路径。例如,当前端通过/app
虚拟路径访问后端时,Nginx 可以将/app
路径下的请求转发到后端服务器,并在转发过程中通过rewrite
规则来屏蔽或者修改部分路径内容。如果后端服务器的真实路径包含一些版本号或者其他不需要暴露给前端的信息,就可以通过rewrite
指令进行屏蔽。比如后端实际路径是http://backend:8080/v2/api/
,可以将前端的/app
请求代理过去,并将路径改写为http://backend:8080/api/
,从而隐藏了v2
这个版本信息。这种方式可以提高后端服务的安全性和可维护性,同时使得前端和后端的路径配置更加灵活。
redis 分布式锁
Redis 分布式锁是用于在分布式系统中控制多个进程或线程对共享资源的访问。
其基本原理是利用 Redis 的单线程特性和原子操作来实现。当一个客户端想要获取锁时,它会尝试在 Redis 中设置一个特定的键值对。例如,使用SETNX
(SET if Not eXists)命令,这个命令会在键不存在时设置键值对。如果设置成功,意味着获取锁成功,客户端可以对共享资源进行操作;如果键已经存在,说明锁已经被其他客户端获取,此时获取锁失败。
为了防止死锁,需要给锁设置一个过期时间。可以使用EXPIRE
命令来设置键的过期时间。但是这样会存在一个问题,SETNX
和EXPIRE
是两个独立的操作,如果在执行SETNX
之后程序崩溃,没有来得及执行EXPIRE
,就会导致锁永远无法释放。为了解决这个问题,可以使用 Redis 的脚本功能,将SETNX
和EXPIRE
操作放在一个脚本中,保证它们的原子性。
在释放锁时,需要注意锁的持有者。只有持有锁的客户端才能释放锁,通常可以通过在设置锁的时候将客户端的唯一标识(如 UUID)作为键值对的值存储在 Redis 中。在释放锁时,先获取键的值,判断是否是自己持有的锁,然后再使用DEL
命令删除键来释放锁。
Redis 分布式锁在分布式系统的多个场景中有重要应用。比如在分布式缓存更新场景中,当多个节点同时尝试更新缓存时,通过分布式锁可以保证只有一个节点能够进行更新操作,避免数据不一致。在分布式任务调度场景中,确保同一时刻只有一个任务执行器能够获取任务并执行,提高系统的稳定性和可靠性。
redis 缓存穿透、击穿、雪崩
一、缓存穿透
缓存穿透是指客户端请求的数据在缓存和数据库中都不存在。例如,恶意用户故意请求一些不存在的 ID 对应的资源。这种情况下,请求会直接穿透缓存到达数据库,增加数据库的压力。
为了应对缓存穿透,可以采用空值缓存的方法。当数据库查询结果为空时,在缓存中也存储一个空值,并设置一个较短的过期时间。这样,下次相同的请求到来时,就可以直接从缓存中获取空值,而不会再访问数据库。还可以使用布隆过滤器,在请求到达缓存之前,先通过布隆过滤器判断请求的数据是否可能存在。布隆过滤器是一种基于概率的数据结构,它可以快速判断一个元素是否在集合中,虽然可能存在误判,但可以有效地过滤掉大部分不存在的数据请求。
二、缓存击穿
缓存击穿是指一个热点数据在缓存过期的瞬间,大量请求同时访问这个数据,导致这些请求直接打到数据库。比如,某一个热门商品的信息在缓存中过期,而此时正好有大量用户查看这个商品的详情,这些请求就会穿透缓存到达数据库。
解决缓存击穿的方法主要是采用分布式锁。当一个请求发现缓存过期时,它会尝试获取一个分布式锁,只有获取到锁的请求才能去数据库查询数据并更新缓存。其他请求则等待,直到缓存被更新。这样可以避免大量请求同时访问数据库。
三、缓存雪崩
缓存雪崩是指在某一时刻,大量缓存同时过期,导致大量请求直接访问数据库,使得数据库压力骤增。例如,设置了大量缓存的过期时间相同,在过期时间到达时,就会出现这种情况。
为了防止缓存雪崩,可以采用缓存过期时间的随机化策略。让缓存的过期时间在一个合理的范围内随机分布,这样就不会出现大量缓存同时过期的情况。另外,还可以设置缓存的多级缓存机制,比如在应用层和数据库层之间设置两级缓存,当一级缓存失效时,二级缓存还可以起到缓冲的作用,减少对数据库的压力。
redis 底层数据结构
Redis 有多种底层数据结构来支持不同的应用场景。
一、简单动态字符串(SDS)
SDS 是 Redis 用来存储字符串的结构。它与普通的 C 字符串相比,有很多优势。SDS 在头部包含了字符串的长度信息,这使得获取字符串长度的操作时间复杂度为 O (1),而 C 字符串需要遍历整个字符串来获取长度,时间复杂度为 O (n)。SDS 还具有动态扩容和缩容的功能,当字符串长度发生变化时,SDS 会自动调整内存大小。例如,当向一个 SDS 中追加字符时,如果剩余空间不足,SDS 会自动重新分配内存,以满足新的长度需求。
二、链表(List)
Redis 的链表结构是一个双向链表,它的节点包含了指向前一个节点和后一个节点的指针。链表在 Redis 中有很多应用,比如在实现列表类型(List)数据结构时就用到了链表。链表可以方便地进行插入和删除操作,在头部和尾部插入或删除节点的时间复杂度为 O (1)。例如,在执行LPUSH
(将一个或多个元素插入到列表头部)和RPUSH
(将一个或多个元素插入到列表尾部)操作时,就是利用了链表的这个特性。
三、字典(Hash)
Redis 的字典是基于哈希表实现的。它用于存储键 - 值对,在实现哈希类型(Hash)数据结构时发挥作用。字典的内部实现包括哈希表和链表。当通过键查找值时,先通过哈希函数计算键的哈希值,然后根据哈希值在哈希表中定位元素。如果出现哈希冲突(不同的键计算出相同的哈希值),则使用链表来解决。这种结构使得字典的查找、插入和删除操作的平均时间复杂度为 O (1),但在最坏情况下可能会退化为 O (n)。
四、跳表(Skip List)
跳表是一种有序的数据结构,它在 Redis 中用于实现有序集合(Sorted Set)。跳表的结构类似于多层链表,通过在每层链表中设置跳跃节点,可以快速地进行查找、插入和删除操作。跳表的查找时间复杂度为 O (log n),它结合了链表的灵活性和平衡树的高效查找特性,在有序集合的实现中能够提供高效的范围查询和排序功能。
五、整数集合(IntSet)
整数集合是 Redis 用于存储整数的集合数据结构。当集合中的元素都是整数,并且数量较少时,Redis 会使用整数集合来存储。整数集合可以根据元素的大小自动升级数据类型,例如,当集合中添加一个比当前存储类型范围更大的整数时,整数集合会自动将所有元素转换为能够容纳新元素的更大的数据类型。
redis 分布式事务
Redis 分布式事务主要是为了在分布式环境下保证多个操作的原子性。
Redis 通过 MULTI、EXEC、DISCARD 和 WATCH 命令来实现事务操作。当一个客户端想要执行一个事务时,首先会使用 MULTI 命令开启一个事务。在这个事务开启后,后续的命令不会立即执行,而是被放入一个事务队列中。例如,在一个简单的转账场景中,有两个 Redis 键分别代表用户 A 和用户 B 的账户余额,使用事务可以这样操作:首先使用 MULTI 命令开启事务,然后使用 INCRBY 和 DECRBY 命令分别对两个键进行增加和减少操作,这些命令会被放入事务队列。
WATCH 命令是用于监控一个或多个键的状态。在事务执行过程中,如果被 WATCH 的键被其他客户端修改,那么这个事务会执行失败。例如,在上述转账场景中,如果在事务执行过程中,用户 A 的账户余额被其他客户端修改,那么当前事务会被取消。
EXEC 命令用于执行事务队列中的所有命令。如果在事务执行过程中没有出现被 WATCH 的键被修改的情况,那么事务队列中的命令会依次执行,并且所有命令的执行是原子性的。如果事务执行成功,会返回一个包含所有命令执行结果的数组;如果事务被取消(比如因为被 WATCH 的键被修改),则返回空值。
DISCARD 命令用于取消一个事务。如果在事务开启后,还没有执行 EXEC 命令,并且不想继续执行这个事务,可以使用 DISCARD 命令来清空事务队列。
不过,Redis 的分布式事务与传统的数据库事务有一些区别。Redis 事务没有像数据库事务那样的回滚机制,它主要是通过 WATCH 命令来保证事务的一致性,并且在事务执行过程中,如果部分命令执行失败,不会自动回滚其他命令。这种设计是基于 Redis 的高性能和简单性考虑的,在实际应用中需要根据具体的场景来合理使用 Redis 的事务功能。
进程、线程、协程对比(应用场景、效率)
一、进程
应用场景:
- 进程独立性强,适合运行多个独立的程序。例如,在一个服务器上同时运行 Web 服务器进程、数据库管理进程和邮件服务进程等。每个进程有自己独立的地址空间,一个进程的崩溃不会影响其他进程。
- 用于资源隔离的场景。比如在云计算环境中,不同用户的应用程序可以运行在不同的进程中,保证用户之间的资源和数据的隔离。
效率:
- 进程的创建和销毁开销较大。创建一个进程需要分配独立的地址空间、初始化各种系统资源等,这可能涉及到大量的系统调用。例如,在 Linux 系统下,fork 系统调用会复制父进程的大部分资源来创建一个子进程,这个过程比较复杂且耗时。
- 进程间通信(IPC)相对复杂且效率较低。因为进程有独立的地址空间,进行通信时需要通过共享内存、管道、消息队列等方式,这些方式都有一定的开销,并且需要考虑同步和互斥等问题。
二、线程
应用场景:
- 在多任务处理且任务之间有共享数据的场景下应用广泛。例如,在一个网络服务器中,多个线程可以同时处理不同客户端的请求,并且这些线程可以共享服务器的缓存、数据库连接等资源。
- 适用于提高程序的响应速度。比如在图形用户界面(GUI)应用中,一个线程可以负责接收用户的输入,另一个线程可以负责更新界面显示,这样可以提高用户操作的响应效率。
效率:
- 线程的创建和销毁成本比进程低。因为线程共享进程的地址空间,创建线程时只需要分配线程相关的资源,如线程栈等,不需要像进程那样复制整个地址空间。
- 线程间通信相对简单且高效。由于线程共享进程的地址空间,它们可以直接访问共享变量,但也容易引发数据安全问题,需要通过互斥锁、信号量等机制来保证数据的同步和互斥。
三、协程
应用场景:
- 适用于高并发的 I/O 密集型任务。例如,在网络爬虫程序中,协程可以高效地处理大量的网络请求和数据解析任务。当遇到 I/O 阻塞(如等待网络响应)时,协程可以暂停执行,将执行权交给其他协程,从而提高程序的整体效率。
- 在异步编程中发挥重要作用。协程可以和异步 I/O 库结合使用,使得代码在逻辑上更像同步代码,但实际上是异步执行的,这样可以提高代码的可读性和可维护性。
效率:
- 协程的切换成本非常低。协程的切换不需要像线程切换那样涉及到系统调用和大量的上下文切换。它是在用户空间内由程序自己控制切换,通常只需要保存和恢复少量的寄存器和栈信息。
- 协程在处理 I/O 密集型任务时效率很高。因为它可以在等待 I/O 操作完成时主动让出执行权,避免了线程或进程在等待 I/O 时的空闲浪费,使得程序能够高效地利用 CPU 资源。
C++ 关键字 inline
在 C++ 中,inline
关键字主要用于函数定义。当一个函数被声明为inline
时,编译器会尝试将函数的代码直接嵌入到调用该函数的地方,而不是进行常规的函数调用。
这种嵌入函数代码的方式有几个优点。首先,对于一些简单的、频繁被调用的函数,比如获取一个对象的属性或者进行简单的数学计算函数,使用inline
可以减少函数调用的开销。例如,有一个简单的函数inline int add(int a, int b) { return a + b; }
,如果在一个循环中频繁调用这个函数,编译器将函数代码嵌入到调用处,就不需要每次都进行函数调用的准备工作(如保存函数的返回地址、参数传递等),从而提高了程序的执行效率。
然而,inline
只是对编译器的一个建议,编译器有权决定是否真正将函数内联。编译器会考虑函数的复杂性、调用频率、代码大小等因素。如果函数体比较复杂,包含了循环、递归或者大量的代码,编译器可能不会将其内联。
另外,inline
函数的定义通常需要放在头文件中。这是因为编译器在编译每个源文件时,需要知道inline
函数的完整定义,才能将其嵌入到调用处。如果只在头文件中声明inline
函数,而在源文件中定义,编译器可能无法正确地内联函数。
在类的定义中,成员函数可以默认被看作是inline
函数。例如,在一个简单的类定义class MyClass { public: int getValue() { return value; } private: int value; };
中,getValue
函数可以被看作是inline
函数,这使得在类的对象频繁调用这个成员函数时,能够提高效率。
C++ 类与结构的区别
在 C++ 中,类(class)和结构(struct)有很多相似之处,但也存在一些区别。
从语法层面来看,定义类和结构的方式非常相似。例如,定义一个类可以是class MyClass { public: int value; void setValue(int v) { value = v; } };
,定义一个结构可以是struct MyStruct { int value; void setValue(int v) { value = v; } };
。它们都可以包含成员变量和成员函数。
然而,在默认的访问控制权限上有区别。在类中,默认的访问控制是private
,这意味着如果没有明确指定访问权限,类的成员变量和成员函数只能在类的内部访问。而在结构中,默认的访问控制是public
,结构的成员变量和成员函数在默认情况下可以从外部访问。例如,对于上述的MyClass
和MyStruct
,在类MyClass
中,value
成员变量和setValue
函数如果没有指定public
权限,外部代码无法直接访问;而在结构MyStruct
中,外部代码可以直接访问value
成员变量和setValue
函数。
在语义上,类通常用于表示具有复杂行为和状态的抽象概念,比如一个图形类可以包含图形的属性(如颜色、形状等)和操作图形的方法(如绘制、旋转等)。而结构更多地用于简单的数据组合,比如表示一个点的坐标结构struct Point { int x; int y; };
,它主要是将相关的数据组合在一起,方便传递和处理。
在继承方面,类支持继承,并且可以通过访问控制符(public
、private
、protected
)来控制继承的方式。结构也支持继承,但是在实际应用中,结构继承相对较少使用,并且在继承时默认是public
继承,这与类的默认private
继承不同。
C++explicit 关键字
在 C++ 中,explicit
关键字主要用于构造函数。当一个构造函数被声明为explicit
时,它不能用于隐式转换。
例如,考虑一个类class MyClass { public: MyClass(int value) { this->value = value; } private: int value; };
,如果没有explicit
关键字,在某些情况下可以进行隐式转换。比如int num = 5; MyClass obj = num;
,编译器会自动将num
转换为MyClass
的对象,这是通过调用MyClass
的构造函数来实现的。
但是,如果将构造函数声明为explicit
,即explicit MyClass(int value) { this->value = value; }
,那么上述的隐式转换就不被允许。这种限制隐式转换的方式有很多好处。
项目中如何维护各控件的生命周期
在项目中维护控件的生命周期是确保程序稳定和高效运行的关键。
对于控件的创建,要根据项目需求在合适的时机创建。比如在图形用户界面(GUI)应用中,在窗口初始化阶段创建那些一开始就需要显示的基本控件,如按钮、文本框等。创建时要考虑资源的分配,避免过度占用内存。例如,创建大量高分辨率的图像控件可能导致内存耗尽,此时需要合理控制创建数量或采用懒加载策略,即当用户需要查看时再创建。
在控件的使用阶段,要注意对其状态的更新和维护。当用户与控件交互时,如点击按钮、在文本框输入内容等,要及时处理这些交互事件,更新控件相关的内部数据和界面显示。同时,对于有依赖关系的控件,要确保它们的状态一致性。例如,在一个表单中,当某个下拉菜单的值改变时,相关的文本框内容可能需要相应更新。
对于控件的销毁,当控件不再使用时,要及时释放其占用的资源。在窗口关闭或者某个功能模块结束时,清理相关的控件。这包括释放控件占用的内存、关闭相关的文件资源(如果控件涉及文件操作)等。例如,一个用于显示实时数据的图表控件,当切换到其他功能页面时,要停止数据更新线程(如果有),并释放图表占用的内存和其他相关资源。而且,在多线程环境下,如果控件被多个线程访问,在销毁时要确保线程安全,避免在一个线程正在使用控件时另一个线程将其销毁。要通过合适的同步机制,如互斥锁、信号量等,来协调不同线程对控件生命周期操作的访问。
信号槽的使用
信号槽机制是一种事件处理机制,广泛应用于图形用户界面编程等场景。
信号是对象发出的事件通知,槽是接收并处理这些信号的函数。例如,在一个按钮控件中,当用户点击按钮时,按钮会发出一个 “clicked” 信号。这个信号可以连接到一个特定的槽函数,该槽函数可以实现相应的功能,比如关闭窗口、执行某个计算等。
在使用信号槽时,首先需要在相关的类中定义信号和槽。信号通常在类的声明中使用signals
关键字来定义,它不需要实现函数体,只是一种事件的声明。槽函数则使用普通的成员函数来定义,可以有参数和返回值。例如,在一个自定义的窗口类中:
class MyWindow : public QWidget {
Q_OBJECT
signals:
void customSignal(int value);
public slots:
void handleSignal(int value) {
// 处理接收到的信号,比如根据value更新界面显示
}
};
然后通过connect
函数将信号和槽连接起来。例如:
MyWindow window;
QObject::connect(&window, SIGNAL(customSignal(int)), &window, SLOT(handleSignal(int)));
当信号被发射时,与之连接的槽函数就会被自动调用。这种机制实现了对象间的松散耦合,因为信号的发送者不需要知道哪些对象会接收并处理信号,只需要发射信号即可。这使得代码的可维护性和可扩展性大大提高,比如在一个复杂的 GUI 应用中,可以方便地添加或修改信号与槽的连接来实现新的功能或修改现有功能。
多线程中信号槽的使用
在多线程环境下使用信号槽机制,需要注意一些特殊的问题和方法。
当一个线程中的对象发出信号,而槽函数在另一个线程中时,信号槽机制可以自动处理线程间的通信。例如,在一个多线程的网络服务器中,一个线程负责接收客户端的连接请求(可以在连接建立时发出一个信号),而另一个线程负责处理具体的业务逻辑(对应的槽函数在这个线程中)。
在这种情况下,信号槽的连接类型变得很重要。默认情况下,信号槽的连接是Qt::AutoConnection
类型。这种类型会根据信号和槽所在的线程情况自动选择合适的连接方式。如果信号和槽在同一个线程,会使用Qt::DirectConnection
,即信号发射时直接调用槽函数;如果信号和槽在不同线程,会使用Qt::QueuedConnection
,信号会被放入接收线程的事件队列中,然后在接收线程的事件循环中处理槽函数。
使用Qt::QueuedConnection
可以确保线程间的安全通信,因为槽函数是在接收线程的事件循环中执行的,避免了多个线程同时访问共享资源可能导致的数据竞争问题。例如,如果一个线程更新了一个共享的数据结构并发出一个信号,另一个线程中的槽函数在处理这个信号时,可以安全地访问和处理这个已经更新的数据结构。
此外,在多线程中使用信号槽时,可以通过QObject::moveToThread
函数将对象移动到特定的线程中。这样,对象发出的信号和连接的槽函数就可以在指定的线程环境中执行。例如,将一个用于数据处理的对象移动到一个专门的工作线程中,该对象发出的信号在这个工作线程中处理相关的槽函数,提高了多线程程序的效率和可管理性。
connect 第五个参数的设置和意义
在Qt
的connect
函数中,第五个参数用于指定信号和槽之间的连接类型,这对于控制信号和槽的执行机制和线程相关行为有着重要意义。
Qt::AutoConnection
是默认的连接类型。当使用这种连接类型时,Qt
会根据信号和槽所在的线程自动决定连接方式。如果信号和槽在同一个线程,就相当于Qt::DirectConnection
,即信号发出时直接调用槽函数。这意味着在单线程应用或者信号和槽在同一线程的情况下,信号的发射和槽函数的执行是同步的,没有额外的延迟。如果信号和槽在不同线程,就相当于Qt::QueuedConnection
,信号会被放入目标线程的事件队列中,在目标线程的事件循环处理到该信号时才执行槽函数。这种自动判断的机制使得在简单的多线程和单线程应用中都能方便地使用信号槽机制,而不需要开发者手动切换连接类型。
Qt::DirectConnection
明确表示信号发射时立即调用槽函数,不管信号和槽是否在同一线程。这种连接类型在某些特殊情况下很有用,比如需要在信号发射后立即执行槽函数,并且开发者能够确保线程安全的情况下。但是,如果在多线程环境中使用不当,可能会导致数据竞争和其他线程安全问题,因为槽函数可能会在非预期的线程环境中执行,直接访问共享资源。
Qt::QueuedConnection
则是将信号放入目标线程的事件队列中。这种方式保证了槽函数在目标线程的事件循环中执行,从而实现了线程间的安全通信。例如,在一个多线程的 GUI 应用中,后台线程发出的信号可以通过这种连接类型在主线程的事件循环中处理槽函数,避免了直接在后台线程操作 GUI 控件可能导致的问题。
Qt::BlockingQueuedConnection
与Qt::QueuedConnection
类似,也是将信号放入目标线程的事件队列,但不同的是,它会阻塞当前线程直到槽函数执行完毕。这种连接类型在需要确保槽函数执行完成后再继续当前线程操作的场景中使用,但要注意避免死锁问题,特别是当两个线程相互等待对方完成信号处理时。
Qt::UniqueConnection
可以保证相同的信号和槽之间只有一个连接。如果多次尝试连接相同的信号和槽组合,只有第一次连接有效,后续连接会被忽略。这种连接类型在避免重复连接导致的意外行为方面很有用,比如在动态加载模块或者多次初始化相关的信号槽连接场景中。
如何获知一大堆重复 int 值中不同 int 的数量(哈希)
使用哈希表来统计一大堆重复的int
值中不同int
的数量是一种高效的方法。
哈希表是一种数据结构,它通过一个哈希函数将数据映射到一个特定的位置。对于int
值,可以创建一个哈希表,其中int
值作为键,值可以是一个简单的计数器或者一个标志。
首先,创建一个合适的哈希表。可以使用C++
标准库中的unordered_map
(它是基于哈希表实现的)。例如:
#include <iostream>
#include <unordered_map>
int main() {
std::unordered_map<int, int> hashTable;
// 假设这里有一堆int值,通过循环添加到哈希表
int intArray[] = {1, 2, 3, 1, 2, 4, 5, 5};
for (int value : intArray) {
if (hashTable.find(value)!= hashTable.end()) {
hashTable[value]++;
} else {
hashTable[value] = 1;
}
}
int differentIntCount = hashTable.size();
std::cout << "不同int值的数量: " << differentIntCount << std::endl;
return 0;
}
在这个示例中,遍历给定的int
值数组。对于每个int
值,先在哈希表中查找。如果找到,说明这个值已经出现过,将其对应的值(这里作为计数器)加 1。如果没找到,说明是一个新的值,将其插入到哈希表中,并将计数器初始化为 1。
最后,哈希表的大小就是不同int
值的数量。这种方法的时间复杂度在平均情况下是比较低的,对于n
个int
值,插入和查找操作的平均时间复杂度接近 O (1),因此可以快速地处理大量的数据。而且,哈希表可以处理任意范围的int
值,不受数据大小和分布的限制,具有很好的通用性和高效性。
内存消耗太大如何改进(位图)
当内存消耗太大时,位图是一种有效的改进工具。位图是一种数据结构,它通过使用位(bit)来表示数据的存在与否,通常用于集合的表示。
假设我们有一个数据集,其中元素的取值范围是已知的,比如 0 到 N - 1。我们可以创建一个大小为 N 位的位图,每个位对应一个可能的元素。如果位为 1,表示对应的元素存在;如果位为 0,表示不存在。例如,在一个记录用户是否访问过网站页面的场景中,假设网站有 1000 个页面,我们可以创建一个长度为 1000 位的位图。当用户访问一个页面时,将对应的位置为 1。
这种方式相比于传统的存储方式,如使用数组或者链表来记录元素,能够大大节省内存。因为在传统方式中,每个元素可能需要占用多个字节的存储空间,而在位图中,每个元素只占用 1 位。例如,在一个存储整数是否在集合中的场景中,使用 32 位整数数组,每个整数占用 4 字节,而位图中每个整数只需要 1 位。
在实际应用中,对于大规模的数据,位图可以有效地减少内存占用。例如,在网络流量分析中,要记录大量 IP 地址是否出现过,使用位图可以用很少的内存来实现。同时,位图的操作也比较简单。可以通过位运算来快速地设置、清除和检查位的状态。比如,要检查一个元素是否存在于位图表示的集合中,只需要通过简单的位运算来获取对应的位的值即可。
不过,位图也有一定的局限性。它适用于元素取值范围相对固定且较小的情况,并且主要用于记录元素的存在与否,对于需要存储更多复杂信息的场景可能不太适用。
位图在值很稀疏时浪费内存如何改进
当位图中的值很稀疏时,确实会存在内存浪费的情况。因为位图是按照固定的范围来分配内存的,即使大部分位都是 0,也会占用相应的空间。
一种改进方法是使用压缩位图。例如,使用游程编码(Run - Length Encoding)来压缩位图。游程编码的基本思想是将连续相同的值用一个值和长度来表示。对于位图来说,就是将连续的 0 或者 1 的长度记录下来。比如,一个位图序列是 “00001100”,可以用游程编码表示为 “0 - 4,1 - 2,0 - 2”。这样可以大大减少存储所需的空间,特别是在值很稀疏的情况下,因为连续的 0 序列可以被有效地压缩。
另一种方法是采用动态位图。动态位图可以根据数据的实际情况动态地调整大小。当发现位图中大部分是 0 时,可以只存储非 0 位的信息。例如,使用一个列表来记录非 0 位的位置和值。对于一个非常稀疏的位图,这种方法可以显著减少内存占用。假设位图中有很少的 1,分布在很大的范围内,使用动态位图可以只记录这些 1 的位置,而不需要为大量的 0 分配空间。
还可以使用分层位图。将位图按照一定的层次进行划分,对于稀疏的部分,可以采用更粗粒度的表示。例如,将一个大的位图分成多个小的子位图,在高层的位图中只记录子位图是否包含非 0 值,然后在需要详细信息的子位图中再进行具体的表示。这样可以在保证一定查询效率的同时,减少内存的浪费。
另外,结合其他数据结构来优化也是一种思路。比如,对于非常稀疏的位图,可以和哈希表结合。用哈希表来记录存在的元素,而位图只作为一个辅助工具,用于快速判断某个范围是否可能存在元素,这样可以根据实际情况灵活地使用两种数据结构的优势,减少内存浪费。
爬虫中如何对爬过的 url 去重(哈希)
在爬虫中,使用哈希来对爬过的 URL 进行去重是一种高效的方法。
首先,可以创建一个哈希表来存储已经爬过的 URL。哈希表可以使用C++
中的unordered_map
或者其他编程语言中的类似数据结构。例如,在 Python 中,可以使用dict
作为哈希表。当爬虫准备爬取一个 URL 时,先对这个 URL 计算哈希值。可以使用常见的哈希函数,如 Python 中的hash()
函数或者自定义的哈希函数。
将计算得到的哈希值作为键,URL 本身或者其他相关信息(如爬取时间等)作为值存储在哈希表中。当遇到一个新的 URL 时,同样计算其哈希值,然后在哈希表中查找。如果哈希值已经存在,说明这个 URL 很可能已经被爬取过,就可以跳过。如果哈希值不存在,则将这个 URL 加入到哈希表中,并进行爬取。
不过,使用哈希去重时要注意哈希冲突的问题。哈希冲突是指不同的 URL 可能计算出相同的哈希值。为了减少哈希冲突的影响,可以采用一些方法。一是使用高质量的哈希函数,尽量使不同的 URL 计算出的哈希值分布均匀。二是在发生哈希冲突时,采用合适的解决方法,如链地址法或者开放定址法。在链地址法中,当发生哈希冲突时,将冲突的元素存储在同一个哈希桶中的链表中;在开放定址法中,通过一定的规则寻找下一个可用的存储位置。
另外,为了提高效率,可以定期清理哈希表。因为随着爬取的 URL 数量越来越多,哈希表可能会变得很大,占用大量内存。可以根据爬取的频率和内存限制等因素,定期删除一些已经很久没有用到的 URL 记录,或者将已经爬取过并且不太可能再次访问的 URL 记录转移到其他存储介质(如磁盘)上。
内存消耗太大的其他思路(字典树)
字典树(Trie 树)是一种可以用于减少内存消耗的有效数据结构,尤其适用于处理字符串相关的数据。
字典树的结构特点是树的节点不存储完整的字符串,而是存储字符。从根节点到叶子节点的路径表示一个字符串。例如,对于单词 “apple”、“apply” 和 “banana”,字典树的根节点没有字符,它的子节点可能包含字符 “a” 和 “b”。从根节点经过字符 “a” 的节点可以进一步延伸到包含 “p” 的节点,以此类推。
在处理大量字符串时,字典树相比于传统的存储方式(如使用数组或者链表存储每个字符串)可以节省内存。因为字典树是共享前缀的,对于有相同前缀的字符串,只需要存储一次前缀部分。例如,“apple” 和 “apply” 在字典树中,前三个字符 “app” 只需要存储一次。
在实际应用中,比如搜索引擎的关键词索引。如果使用普通的存储方式存储大量的搜索关键词,会占用大量的内存。而使用字典树,可以有效地减少内存消耗。并且,字典树还方便进行字符串的查找、插入和删除操作。查找一个字符串时,从根节点开始,沿着字符路径向下查找,如果能够顺利到达叶子节点,说明字符串存在。插入一个新字符串时,按照字符串的字符顺序依次在字典树中创建节点或者利用已有的节点。删除一个字符串时,可以通过标记节点或者调整节点的连接来实现。
然而,字典树也有一些缺点。它的构建过程可能比较复杂,特别是当插入大量不同的字符串时,需要频繁地创建和调整节点。而且,字典树可能会因为节点过多而占用大量的内存,虽然它比传统方式节省内存,但在某些极端情况下(如处理非常长且数量巨大的字符串),仍然可能出现内存问题。此时,可以结合其他方法,如对字典树进行压缩或者采用多层字典树结构来进一步优化内存使用。
爬取文章出现重复或类似情况如何去重并统计不同文章数量
当爬取文章出现重复或类似情况时,去重并统计不同文章数量可以通过多种方法实现。
首先,可以使用文本指纹技术。文本指纹是一种能够代表文本内容的特征标识。例如,使用哈希函数来计算文章的哈希值作为文本指纹。对于每篇爬取到的文章,先将文章内容转换为合适的格式,如去除格式标记、提取纯文本内容等。然后计算其哈希值,将哈希值存储在哈希表中。当遇到新的文章时,同样计算哈希值并在哈希表中查找。如果哈希值相同,说明文章很可能是重复的,可以跳过。这种方法简单高效,但可能会受到哈希冲突的影响。为了减少哈希冲突,可以使用更复杂的哈希函数或者结合其他特征来判断。
另一种方法是使用相似度计算。可以计算文章之间的相似度来判断是否重复。例如,使用余弦相似度或者编辑距离等指标。对于两篇文章,先将它们转换为词向量或者字符向量。如果使用词向量,可以通过对文章进行分词,然后统计每个词的出现频率来构建词向量。计算两篇文章词向量的余弦相似度,如果相似度很高(如超过一定的阈值),则认为文章是重复或者类似的。编辑距离则是通过计算将一篇文章转换为另一篇文章所需的最少编辑操作次数(如插入、删除、替换字符)来判断相似度。
在统计不同文章数量时,结合上述的去重方法。每次判断文章为新的(通过哈希值或者相似度判断)时,将不同文章数量加 1。同时,可以建立一个存储文章信息的数据结构,如列表或者字典,用来记录不同文章的内容、来源、爬取时间等信息,方便后续的分析和使用。此外,为了提高效率和准确性,可以对文章进行预处理,如去除停用词、进行词干提取等操作,这样可以更好地突出文章的核心内容,提高去重和相似度计算的效果。
https 安全原理
https 是在 http 基础上加入了 SSL/TLS 协议来实现安全通信的。
首先,在客户端和服务器建立连接时,会进行握手过程。客户端向服务器发送请求,服务器会将自己的证书发送给客户端。这个证书包含了服务器的公钥以及一些相关信息,证书是由权威的证书颁发机构(CA)颁发的,CA 会对服务器的身份进行验证。客户端收到证书后,会通过内置的 CA 根证书来验证服务器证书的合法性,确保自己是与真正的目标服务器通信,而不是被中间人攻击所欺骗。
接着,双方会协商加密算法和密钥。基于非对称加密算法,客户端可以使用服务器的公钥对一个随机生成的主密钥进行加密并发送给服务器,只有拥有对应私钥的服务器才能解密得到主密钥。之后,双方利用这个主密钥生成对称加密密钥,后续的通信数据都使用对称加密算法进行加密。对称加密算法比非对称加密算法效率高,这样既保证了密钥交换的安全性,又保证了数据传输的效率。
在数据传输过程中,使用协商好的对称加密密钥对数据进行加密,这样即使数据被拦截,攻击者也无法获取其中的内容。同时,消息还会有完整性校验机制,通过哈希函数计算消息的摘要,在接收端可以验证消息在传输过程中是否被篡改。这种结合了非对称加密、对称加密和消息完整性校验的方式,确保了 https 通信的保密性、身份验证和数据完整性。
对设计模式的理解
设计模式是在软件开发过程中,针对反复出现的问题所总结归纳出的通用解决方案。
以单例模式为例,它保证一个类只有一个实例,并提供一个全局访问点。在很多场景中,比如数据库连接池,只需要一个实例来管理连接资源,避免多次创建和销毁连接带来的开销。通过将构造函数设为私有,在类内部创建唯一实例,并通过一个静态方法获取该实例,实现了对实例的控制。
工厂模式则是将对象的创建和使用分离。比如在一个游戏开发中,创建不同类型的游戏角色。可以有一个角色工厂类,根据传入的参数决定创建哪种角色。这样,当需要添加新的角色类型时,只需要在工厂类中修改创建逻辑,而不影响使用角色的其他代码,提高了代码的可维护性和可扩展性。
观察者模式用于建立一种一对多的依赖关系,当一个对象的状态发生变化时,所有依赖它的对象都会得到通知并自动更新。在图形用户界面中,当一个数据模型发生变化时,与之关联的多个视图(如表格视图、图表视图等)都需要更新,就可以使用观察者模式。模型是被观察的对象,视图是观察者,模型状态改变时通知视图更新。
设计模式有助于提高软件的质量,使代码更易于理解、维护和扩展。它们遵循了面向对象设计的原则,如开闭原则(对扩展开放,对修改关闭)等,通过复用经过实践检验的设计思路,减少了开发过程中的复杂性和错误。
聚簇索引和非聚簇索引
聚簇索引是一种特殊的索引,它决定了表中数据的物理存储顺序。对于 InnoDB 存储引擎(MySQL 的一种常用引擎),如果没有指定主键,会选择一个唯一非空的索引作为聚簇索引,如果都没有,则会自动创建一个隐藏的主键作为聚簇索引。
在聚簇索引中,叶子节点直接包含了行数据。这意味着通过聚簇索引查找数据非常高效,因为一旦找到了索引的叶子节点,就直接找到了数据本身。例如,以学生表的学号作为聚簇索引,当根据学号查找学生信息时,沿着索引树找到叶子节点,叶子节点就是对应的学生记录。
非聚簇索引则不同,它的叶子节点并不包含行数据,而是包含了指向数据行的指针(在 InnoDB 中是主键值)。例如,在学生表中,建立一个姓名的非聚簇索引,当通过姓名查找学生时,先在姓名索引中找到对应的记录,该记录只包含姓名和主键值,然后再通过主键值去聚簇索引中查找完整的学生信息。
聚簇索引在范围查询方面有优势,因为数据是按照索引顺序存储的,所以可以快速地获取范围内的所有数据。但聚簇索引也有一些缺点,比如更新聚簇索引列的值可能会导致数据行的物理移动,成本较高。非聚簇索引在插入和更新数据时对索引的影响相对较小,但在查询数据时可能需要额外的步骤(通过指针查找数据),特别是在查询多个列时,如果这些列不在非聚簇索引中,可能需要多次回表查询。
abc 联合索引,查 c 的查找过程(select c from xx where a = 1 and b = 1)
当有 abc 联合索引时,对于查询 “select c from xx where a = 1 and b = 1”,查找过程如下。
首先,数据库会从联合索引的根节点开始搜索。联合索引是按照 a、b、c 的顺序构建的。在索引树中,节点是按照索引列的值进行排序的。从根节点开始,根据 a 的值进行查找。由于要查找 a = 1 的情况,会沿着 a 值为 1 的分支向下搜索。
当找到 a 值为 1 的子节点后,再根据 b 的值进一步查找。因为要查找 b = 1 的情况,在这个子节点下继续沿着 b 值为 1 的分支搜索。
最后,在满足 a = 1 和 b = 1 的叶子节点处,可以获取到 c 的值。因为这是一个联合索引,索引的叶子节点中包含了 a、b、c 的值。在这个查询中,只需要获取 c 的值即可。这种联合索引的查询方式利用了索引的有序性和前缀匹配的特点,通过逐步缩小搜索范围,快速定位到满足条件的记录。并且由于查询的列 c 在联合索引中,不需要再进行额外的回表操作,直接从索引的叶子节点中获取所需信息,提高了查询的效率。
找到叶子节点后 innodb 引擎会做什么,还需要回表么(因为是联合索引,c 被覆盖了不需要回表,叶子节点直接拿到)
当 InnoDB 引擎在联合索引中找到叶子节点后,如果查询的列都被联合索引覆盖(如上述查询只查询 c 且 abc 是联合索引),则不需要回表。
在这种情况下,叶子节点已经包含了查询所需的信息。InnoDB 引擎会直接从叶子节点提取数据。对于查询操作,它会将提取到的数据进行相应的处理,比如将结果集组装起来。如果有排序等操作,会在内存中对获取到的数据进行排序(如果数据量较小可以在内存完成)或者使用临时文件辅助排序(如果数据量较大)。如果查询有一些限制条件,如LIMIT
子句,会在获取数据后根据限制条件筛选出符合要求的结果。
如果查询涉及到聚合操作(如SUM
、COUNT
等),会在获取到的数据上进行相应的聚合计算。例如,如果是COUNT(*)
查询,会统计符合条件的记录数量。同时,如果查询有分组操作(如GROUP BY
),会根据分组列对数据进行分组,并对每个组内的数据进行相应的处理,如计算每个组的聚合值。整个过程不需要再去表中查找其他数据,因为联合索引已经满足了查询需求,这样可以提高查询效率,减少磁盘 I/O 操作。
mysql 回表是什么?
在 MySQL 中,回表是一个与索引查询相关的概念。
MySQL 存储引擎(如 InnoDB)使用 B + 树来构建索引。当查询通过索引查找数据时,如果查询的列全部包含在索引中,那么可以直接从索引中获取数据,这是比较高效的情况。
然而,假如有一个索引只包含部分列,而查询语句需要获取其他不在索引中的列的值。例如,有一个表包含列 id(主键)、name 和 age,在 name 列上建立了一个索引。当执行查询 “SELECT age, name FROM table WHERE name = 'John'”,可以通过 name 索引找到满足条件的记录。因为索引中包含 name 列,所以 name 的值可以直接从索引中获取。
但如果查询是 “SELECT age, name, id FROM table WHERE name = 'John'”,虽然 name 和 id 可以通过索引定位到(id 是因为索引叶子节点存储了主键值),但 age 列不在 name 索引中。此时,在通过索引找到对应的主键后,需要根据主键去聚簇索引(在 InnoDB 中数据是根据聚簇索引存储的)或者数据文件(对于其他存储引擎)中查找完整的数据记录,这个过程就叫做回表。
回表操作会增加查询的时间成本,因为它涉及到额外的磁盘 I/O 或者内存访问。为了减少回表操作,可以考虑建立覆盖索引,即让索引包含查询中经常用到的列,这样就可以尽量从索引中获取数据,避免回表。
说说 tcp 三次握手
TCP(传输控制协议)三次握手是建立可靠连接的过程。
首先,客户端向服务器发送一个 SYN(同步序列号)包,这个包中的序列号是客户端随机生成的一个初始序列号,记为 ISN(Initial Sequence Number),比如设为 x。此时,客户端进入 SYN - SENT 状态,表示已经发送了 SYN 请求,等待服务器的响应。
然后,服务器收到客户端的 SYN 包后,会返回一个 SYN - ACK 包。这个包中,服务器也会生成自己的初始序列号,设为 y,同时确认号(ACK)为 x + 1,表示收到了客户端的序列号为 x 的 SYN 包。此时,服务器进入 SYN - RECEIVED 状态,等待客户端的最终确认。
最后,客户端收到服务器的 SYN - ACK 包后,会发送一个 ACK 包,确认号为 y + 1,以确认收到了服务器的序列号为 y 的 SYN 包。此时,客户端进入 ESTABLISHED 状态,服务器收到这个 ACK 包后,也进入 ESTABLISHED 状态,至此,TCP 连接建立成功。
三次握手的主要目的是确保双方都有发送和接收数据的能力,并且协商好初始序列号,为后续的数据传输提供可靠的基础。通过这种方式,可以避免旧的重复连接请求、网络延迟等因素导致的错误连接,保证连接的准确性和可靠性。
握手时,客户端不返回 ack,不断重新连接,服务端会怎样
在 TCP 三次握手过程中,如果客户端不返回 ACK,却不断重新发送 SYN 请求,这会对服务端产生一定的影响。
服务端在收到客户端的 SYN 请求后,会为这个连接请求分配一定的资源,如存储连接相关信息的队列空间等。如果客户端不返回 ACK,服务端会等待一段时间,这个等待时间是由 TCP 协议中的超时机制决定的。
当等待时间结束后,服务端会认为这个连接请求已经超时,会清理为这个连接分配的资源。然而,如果客户端不断重新连接,服务端会不断收到新的 SYN 请求,会持续为这些请求分配资源。
随着未完成的连接请求越来越多,服务端可能会出现半连接队列(SYN 队列)溢出的情况。半连接队列是用于存储已经收到 SYN 请求但还未完成三次握手的连接信息的队列。一旦队列溢出,服务端可能会开始丢弃新收到的 SYN 请求,导致正常的连接请求无法被处理,从而影响服务端的正常服务。
这种情况可能是由于客户端出现故障、网络问题或者恶意攻击(如 SYN 洪水攻击)导致的。在面对这种情况时,服务端需要采取一些措施来保护自己,避免资源被耗尽。
怎么减缓、应对方法(超时、来源 IP 哈希判断)
为了应对客户端在 TCP 握手过程中不返回 ACK 却不断重新连接的情况,可以采用多种方法。
一、超时机制调整
可以合理调整服务端的超时时间。在 TCP 协议中,服务端等待客户端 ACK 的时间是有一定限制的。通过适当延长这个超时时间,可以给网络波动等情况更多的缓冲时间,让客户端有机会完成三次握手。但延长超时时间也有风险,如果超时时间过长,会导致半连接队列被无效连接占用的时间过长,浪费资源。
同时,对于超时的连接,要及时清理相关资源。当服务端判断一个连接请求超时后,要确保彻底清除为这个连接分配的内存、队列空间等资源,避免资源泄漏。
二、来源 IP 哈希判断
可以采用来源 IP 哈希判断的方法。服务端可以对连接请求的客户端 IP 进行哈希计算,根据哈希结果将连接请求分配到不同的处理区域或者队列。
如果发现某个 IP 地址频繁发送未完成的连接请求,可能是出现了问题。可以对这个 IP 地址进行限制,例如,在一段时间内限制该 IP 地址的连接请求次数。或者将这个 IP 地址放入一个观察名单,对其后续的连接请求进行更严格的检查。
另外,还可以结合防火墙等安全设备,设置规则来过滤掉来自可疑 IP 地址的连接请求。对于被判定为恶意攻击的 IP 地址,可以直接拒绝其所有连接请求,保护服务端免受攻击。
HTTP 状态码
HTTP 状态码是服务器返回给客户端的三位数字代码,用于表示请求的处理结果。
1xx(信息性状态码)
这类状态码主要是提供信息,表示请求已经被接收,正在处理中。例如,100 Continue 状态码。当客户端发送一个包含较大主体内容的请求(如 POST 请求)时,先发送请求头,服务器如果认为可以接收这个请求,会返回 100 Continue,告知客户端可以继续发送主体内容。
2xx(成功状态码)
这表示客户端的请求成功被服务器接收、理解并处理。最常见的是 200 OK,表示请求成功,服务器返回了请求的资源。还有 201 Created,通常用于 POST 请求后,表示服务器已经成功创建了新的资源。例如,当客户端向服务器提交一个新的用户注册信息,服务器成功创建用户后,可以返回 201 Created 状态码。
3xx(重定向状态码)
用于指示客户端需要进行额外的操作才能完成请求。301 Moved Permanently 表示请求的资源已经永久移动到了新的位置,客户端应该使用新的 URL 来访问。302 Found(或者 307 Temporary Redirect)表示资源临时移动,客户端应该临时使用新的 URL 来访问。例如,网站进行域名更换或者页面 URL 结构调整时,可能会用到 301 或 302 状态码来引导用户访问正确的页面。
4xx(客户端错误状态码)
这类状态码表示客户端发送的请求有问题。400 Bad Request 通常是因为客户端发送的请求格式错误,如请求参数不符合要求或者请求头信息有误。401 Unauthorized 表示客户端没有提供有效的身份验证信息,当需要用户登录才能访问的资源被未授权的用户访问时,会返回这个状态码。403 Forbidden 表示服务器理解了请求,但拒绝执行,可能是因为客户端没有足够的权限访问资源。404 Not Found 是很常见的状态码,表示服务器无法找到客户端请求的资源。
5xx(服务器错误状态码)
这意味着服务器在处理请求时出现了错误。500 Internal Server Error 是一个比较笼统的服务器内部错误状态码,可能是由于服务器代码出错、数据库连接问题等多种原因导致。503 Service Unavailable 表示服务器暂时无法提供服务,可能是因为服务器正在维护、过载或者出现其他故障。
token 和 session、cookie 的区别
token 是一种客户端存储的凭证。它通常是在用户认证成功后,由服务器生成并返回给客户端。客户端在后续向服务器发送请求时,将 token 放在请求头或请求参数中。token 可以包含用户的身份信息,经过加密处理,具有一定的安全性。例如,在基于 JSON Web Token(JWT)的认证系统中,token 是一个独立的字符串,服务器通过验证这个 token 来确定用户身份,无需在服务器端存储额外的用户会话信息,减轻了服务器的存储负担。
session 是服务器端存储的一种结构。当用户首次访问服务器时,服务器会创建一个 session,这个 session 有一个唯一的标识符。服务器通过这个标识符来区分不同用户的会话。session 中可以存储用户的登录状态、购物车信息等各种与用户相关的数据。例如,在一个电商网站中,用户将商品加入购物车,商品信息会存储在服务器端的 session 中。
cookie 是一种存储在客户端的小文本文件,用于帮助服务器识别客户端。它可以存储各种信息,包括用户的偏好设置、登录状态等。在与 session 相关的场景中,cookie 常被用来存储 session 的标识符。当客户端向服务器发送请求时,会自动带上 cookie,服务器通过读取 cookie 中的 session 标识符来获取对应的 session 信息。与 token 不同,cookie 主要是作为一种在客户端和服务器之间传递信息的载体,本身并不包含完整的用户认证信息。而且,cookie 有一些安全相关的属性,如可以设置是否仅通过 https 传输、是否允许脚本访问等。
HTTP 1/2/3 版本的区别
HTTP 1.0 是早期的 HTTP 协议版本。它的特点是简单直接,每次请求 - 响应都需要建立一个新的 TCP 连接,这种方式在处理大量请求时效率较低,因为建立 TCP 连接的三次握手过程有一定的时间成本。而且它没有对头部信息进行优化,随着网页内容变得复杂,头部信息可能会很长,增加了传输的数据量。例如,一个包含多个图片和脚本的网页,每个资源都需要单独建立 TCP 连接进行请求。
HTTP 1.1 在 1.0 的基础上进行了改进。它支持持久连接,一个 TCP 连接可以用于多个请求 - 响应,减少了建立连接的次数,提高了性能。同时,它引入了管道机制,允许客户端在一个连接中发送多个请求,而不用等待每个请求的响应。但是,管道机制也存在一些问题,如队首阻塞,当第一个请求的响应被阻塞时,后面的请求也无法得到响应。比如,在网络延迟较高的情况下,如果第一个请求的某个数据包丢失,需要等待重传,后面的请求就会被延迟处理。
HTTP 2.0 在性能上有了更大的提升。它采用二进制分帧层,将 HTTP 消息分解为更小的帧进行传输,这样可以在一个 TCP 连接上同时发送多个请求和响应的帧,避免了队首阻塞。通过这种方式,多个请求和响应可以交错进行,提高了网络利用率。它还对头部信息进行了压缩,使用 HPACK 算法减少了头部信息的大小。另外,HTTP 2.0 支持服务器推送,服务器可以主动向客户端推送一些可能会用到的资源,如样式表、脚本等,减少了客户端再次请求的时间。
HTTP 3.0 则是基于 UDP 协议的。由于 HTTP 2.0 基于 TCP,在一些网络环境下,如高丢包率的网络中,TCP 的重传机制会导致性能下降。HTTP 3.0 使用 QUIC 协议,它在 UDP 基础上实现了类似 TCP 的功能,如可靠传输、拥塞控制等,同时继承了 UDP 的低延迟特性。并且它进一步优化了连接建立过程,减少了连接建立的时间,使得在复杂网络环境下的性能更加出色。
HTTP Cookie 作用
HTTP Cookie 具有多种重要作用。
首先,在用户身份识别方面,当用户登录网站后,服务器会在响应中发送一个包含用户身份标识的 Cookie。此后,用户再次访问该网站的其他页面时,浏览器会将这个 Cookie 发送给服务器,服务器通过识别 Cookie 中的信息,就能知道是哪个用户在访问。例如,在一个在线银行系统中,用户登录后,服务器生成一个包含用户账号等身份信息的 Cookie,后续用户进行转账、查询余额等操作时,服务器通过 Cookie 识别用户身份,确保操作的安全性和准确性。
其次,Cookie 可以用于记录用户的浏览状态。比如在一个多步骤的表单填写过程中,服务器可以通过 Cookie 来记录用户已经填写的步骤。即使用户在填写过程中离开页面,再次回来时也可以继续之前的步骤。在购物网站的购物流程中,用户将商品加入购物车后,购物车的商品信息可以通过 Cookie 在一定程度上保存,方便用户继续购物。
Cookie 在广告追踪方面也有应用。广告商可以通过在不同网站设置 Cookie 来追踪用户的浏览行为,了解用户的兴趣爱好,从而推送更有针对性的广告。不过,这种广告追踪功能也引发了隐私问题,因为用户的浏览行为可能会被过度收集和利用。
此外,Cookie 在会话管理方面起着关键作用。服务器可以通过设置一个会话相关的 Cookie,来跟踪用户的会话状态,如判断用户的会话是否已经过期等。这可以确保网站服务的连贯性和安全性,例如,在一些需要用户登录才能访问的网站功能中,通过 Cookie 来管理会话可以防止未经授权的访问。
TCP 拥塞控制方法
TCP 拥塞控制主要有以下几种方法。
慢启动是 TCP 拥塞控制的初始阶段。在连接建立初期,TCP 发送方有一个较小的拥塞窗口(cwnd),通常初始拥塞窗口大小为 1 个 MSS(最大报文段长度)。每当收到一个对新发送数据的确认(ACK),拥塞窗口就会加倍。例如,发送方发送了 1 个 MSS 的数据,收到 ACK 后,拥塞窗口变为 2 个 MSS,下次就可以发送 2 个 MSS 的数据。这种指数增长的方式可以快速探测网络的可用带宽,但如果增长速度过快,可能会导致网络拥塞。
当拥塞窗口增长到一个阈值(ssthresh)时,进入拥塞避免阶段。在这个阶段,拥塞窗口不再是加倍增长,而是线性增长。即每收到一个 ACK,拥塞窗口增加 1 个 MSS。这种方式可以更加谨慎地利用网络带宽,避免过度占用网络资源。例如,如果当前拥塞窗口为 8 个 MSS,收到一个 ACK 后,拥塞窗口变为 9 个 MSS。
当检测到网络拥塞时,有不同的处理方式。如果收到三个重复的 ACK,这被称为快速重传。发送方会立即重传丢失的数据段,然后将拥塞窗口减半,同时将 ssthresh 设置为当前拥塞窗口大小。例如,若当前拥塞窗口为 16 个 MSS,收到三个重复的 ACK 后,拥塞窗口变为 8 个 MSS,ssthresh 也变为 8 个 MSS。如果是发生超时,发送方会将拥塞窗口设置为 1 个 MSS,同时将 ssthresh 设置为原来拥塞窗口大小的一半,然后重新进入慢启动阶段。
此外,还有一些改进的拥塞控制算法,如 TCP Reno、TCP NewReno 等。TCP Reno 在快速重传后,会进入快速恢复阶段,在这个阶段,拥塞窗口不是像传统方法那样直接减半,而是根据一定的规则进行调整,以更快地恢复数据发送,减少网络拥塞对数据传输的影响。
epoll select poll 优缺点 底层 应用场景 连接数限制 效率对比
select
- 底层:select 通过一个 fd_set 结构体来存储要监视的文件描述符集合。在调用 select 时,内核会遍历这个集合,检查每个文件描述符的状态。
- 优点:跨平台性好,在很多操作系统上都能使用,接口简单,易于理解和使用。
- 缺点:单个进程可监视的文件描述符数量有限,通常由 FD_SETSIZE 限制,这个限制在不同系统可能比较小。每次调用 select 都需要把 fd_set 从用户空间拷贝到内核空间,并且在返回时还要从内核空间拷贝回用户空间,当监视的文件描述符较多时,这会带来较大的开销。而且它采用轮询的方式检查文件描述符状态,效率较低,即使只有少量文件描述符就绪,也要遍历整个集合。
- 应用场景:适用于连接数较少且对性能要求不是特别高的场景,比如简单的小型网络服务程序。
- 连接数限制:受 FD_SETSIZE 限制,一般较小。
- 效率:在连接数较多时效率较低,因为轮询和频繁的数据拷贝会消耗大量时间。
poll
- 底层:poll 使用一个 pollfd 结构数组来存储要监视的文件描述符及其事件。内核会遍历这个数组来检查文件描述符状态。
- 优点:没有像 select 那样的文件描述符数量限制,它是基于链表存储的,可以监视更多的文件描述符。接口相对简单,和 select 类似,容易上手。
- 缺点:同样需要将大量的 pollfd 结构从用户空间拷贝到内核空间,并且在返回时拷贝回来,这在监视大量文件描述符时开销较大。也是采用轮询的方式,效率会随着监视文件描述符数量的增加而降低。
- 应用场景:适用于需要监视较多文件描述符,且对性能要求稍高一些,但对延迟不太敏感的场景,比如一些中大型网络服务。
- 连接数限制:理论上无限制,但受系统资源限制。
- 效率:比 select 在处理较多文件描述符时稍有优势,但随着文件描述符增多,效率下降仍然明显。
epoll
- 底层:epoll 是基于事件驱动的。它在内核中有一个红黑树来管理所有要监视的文件描述符,还有一个就绪列表来存储已经就绪的文件描述符。当文件描述符状态发生变化时,内核会通过回调函数将其加入就绪列表。
- 优点:没有文件描述符数量的硬限制,理论上可以监视大量的文件描述符。采用事件驱动机制,只有当文件描述符就绪时才会通知应用程序,避免了像 select 和 poll 那样的轮询,大大提高了效率。内核和用户空间通过共享内存来传递就绪文件描述符信息,减少了数据拷贝的开销。
- 缺点:只在 Linux 系统下有效,跨平台性差。虽然效率高,但代码实现相对复杂一些。
- 应用场景:适用于高并发的网络服务器,如大型的 Web 服务器、消息队列服务器等,对高性能和大量连接处理有要求的场景。
- 连接数限制:理论上无限制。
- 效率:在处理大量连接和高并发场景下效率远远高于 select 和 poll,能够有效利用系统资源,减少 CPU 的占用。
设计一个直播间弹幕模块,上千个直播间,每个里面上百人,有人发弹幕该直播间其他人都能看见(每个用户维护一个弹幕队列)
首先,对于直播间和用户的管理,可以采用分层的数据结构。在服务器端,有一个直播间管理模块,用于记录每个直播间的基本信息,包括直播间 ID、主播信息等,同时维护一个直播间与用户的映射关系,即每个直播间对应的用户列表。
对于每个用户,在服务器端为其维护一个弹幕队列。当用户进入直播间时,服务器会为该用户创建一个专属的弹幕队列。这个队列用于存储该用户在这个直播间内应该看到的弹幕。
当有用户发送弹幕时,服务器先根据直播间 ID 找到该直播间内的所有用户列表。然后,对于列表中的每个用户,将弹幕消息添加到他们对应的弹幕队列中。
为了高效地存储弹幕消息,可以设计一个弹幕消息结构体,包含发送者信息(如用户 ID、昵称)、弹幕内容、发送时间等。这样,在将弹幕添加到用户队列时,可以按照一定的顺序(如发送时间)进行排列,方便后续用户查看。
在客户端,用户界面需要定期从服务器端获取自己弹幕队列中的新消息。可以采用轮询或者长连接(如 WebSocket)的方式。轮询方式是每隔一段时间向服务器发送请求,询问是否有新的弹幕消息。长连接方式则是建立一个持久的连接,当有新的弹幕消息时,服务器可以主动将消息推送给客户端。
另外,为了确保数据的一致性和可靠性,需要对弹幕消息进行持久化存储。可以将弹幕消息存储到数据库中,在服务器启动或者出现故障恢复时,能够重新加载弹幕消息,保证用户不会错过重要的弹幕。
弹幕如何推送给其他用户(长连接)
使用长连接来推送弹幕给其他用户,比如采用 WebSocket 技术。
在服务器端,当用户进入直播间时,建立一个 WebSocket 连接。这个连接会一直保持打开状态,直到用户离开直播间或者出现网络故障等情况。
当有用户发送弹幕时,服务器首先通过直播间与用户的映射关系找到该直播间内的所有用户。然后,对于每个用户的 WebSocket 连接,将弹幕消息封装成合适的格式(如 JSON 格式),其中包含发送者信息、弹幕内容、发送时间等。
接着,通过 WebSocket 的 API,将封装好的弹幕消息发送到对应的客户端。客户端在接收到消息后,对消息进行解析,将弹幕显示在直播间的弹幕区域。
为了确保消息能够可靠地发送,需要考虑网络异常情况。如果在发送消息过程中出现网络故障或者连接中断,服务器应该有相应的机制来处理。例如,可以设置一个消息缓存队列,当连接恢复时,将缓存的消息重新发送给客户端。
同时,为了避免消息发送过于频繁导致客户端性能问题,可以对弹幕消息进行一定的频率控制。例如,对于短时间内大量发送的相似弹幕,可以进行合并或者延迟发送,以保证用户能够有较好的观看体验。
另外,对于不同类型的弹幕(如普通弹幕、礼物弹幕、管理员弹幕等),可以在发送时设置不同的优先级。高优先级的弹幕可以优先发送,并且在客户端显示时可以有不同的样式,以突出重要信息。
高并发情况怎么办,怎么优化(每个房间维护弹幕队列)
在高并发情况下,即大量用户同时发送和接收弹幕,需要进行多方面的优化。
对于每个直播间维护弹幕队列,可以采用高效的数据结构。例如,使用环形缓冲区来作为弹幕队列。环形缓冲区可以在固定大小的内存空间内高效地存储和读取数据。当弹幕队列满时,可以采用覆盖旧数据或者其他策略来处理新的弹幕消息,具体根据业务需求决定。
在服务器端,可以采用多线程或者异步 I/O 来处理弹幕的发送和接收。对于每个直播间,可以有一个专门的线程或者异步任务来处理弹幕队列。当有用户发送弹幕时,将弹幕消息放入队列的操作可以是异步的,这样可以快速响应发送者,避免阻塞。而对于从队列中取出弹幕消息并发送给其他用户的操作,可以由专门的线程来负责,提高效率。
为了减少数据库的压力,对于弹幕消息的持久化,可以采用批量写入的方式。不是每条弹幕都立即写入数据库,而是定期将一定数量的弹幕消息批量写入。同时,可以使用缓存技术,将一些热门直播间或者经常访问的弹幕消息存储在缓存中,减少对数据库的频繁访问。
在网络层面,可以对弹幕消息进行压缩。例如,使用一些高效的文本压缩算法,将弹幕消息的大小减小,从而减少网络传输的带宽占用,提高传输效率。
另外,在服务器端可以设置流量控制和负载均衡机制。流量控制可以限制每个用户或者每个直播间的弹幕发送频率,避免过度占用资源。负载均衡机制可以将用户请求均匀地分配到多个服务器或者服务器集群中,防止某个服务器过载。例如,根据直播间的热度或者用户数量,将用户请求动态地分配到不同的服务器上,确保每个服务器的负载相对均衡。
有序数组去重不用额外空间
对于有序数组去重且不使用额外空间的问题,可以利用有序数组的特性来解决。
由于数组是有序的,相同的元素会相邻。可以使用两个指针,一个慢指针和一个快指针。慢指针用于标记已经处理好的不重复元素的位置,快指针用于遍历数组。
开始时,慢指针和快指针都指向数组的开头。当快指针开始移动时,它会逐个检查元素。如果快指针所指的元素和慢指针所指的元素不相等,这意味着遇到了一个新的不重复元素。此时,将慢指针向后移动一位,并将快指针所指的元素复制到慢指针所指的位置。然后快指针继续向后移动。如果快指针所指的元素和慢指针所指的元素相等,说明是重复元素,快指针继续向后移动,而不进行复制操作。
例如,对于有序数组 {1, 1, 2, 2, 3, 4, 4},开始时慢指针和快指针都指向第一个元素 1。当快指针移动到第二个 1 时,因为它和慢指针所指元素相等,快指针继续移动。当快指针移动到 2 时,和慢指针所指元素不同,将慢指针向后移动一位,并将 2 复制到慢指针位置,此时数组变为 {1, 2, 2, 2, 3, 4, 4},然后快指针继续移动,重复这个过程。最终,数组会变为 {1, 2, 3, 4},实现了去重。这种方法不需要额外的空间,只是在原数组上通过指针操作来完成去重,利用了有序数组中相同元素相邻的特点,提高了空间效率。
二叉树度为 0 和度为 2 的数量关系
在二叉树中,度为 0 的节点(叶子节点)和度为 2 的节点存在特定关系。设二叉树中度为 0 的节点个数为 n0,度为 1 的节点个数为 n1,度为 2 的节点个数为 n2。根据二叉树的性质,二叉树的节点总数 N = n0 + n1 + n2。同时,二叉树中边的总数 E = N - 1(因为除了根节点,每个节点都有一条入边),而边的总数又可以表示为 E = n1 + 2 * n2(度为 1 的节点有 1 条边,度为 2 的节点有 2 条边)。由此可得 n0 + n1 + n2 - 1 = n1 + 2 * n2,化简后得到 n0 = n2 + 1。这意味着在二叉树中,度为 0 的节点个数总是比度为 2 的节点个数多 1。例如,当度为 2 的节点有 3 个时,度为 0 的节点必然有 4 个。这个关系在分析二叉树的结构和相关算法时非常重要,比如在计算二叉树的高度、遍历二叉树等操作中,可以根据叶子节点的情况来推断度为 2 的节点数量,进而更好地理解二叉树的整体特性。
哈夫曼树构建过程
哈夫曼树构建基于贪心算法,旨在构建一棵带权路径长度最短的二叉树。
首先,有一组给定的权值,每个权值代表一个节点。初始时,将每个节点看作一棵独立的单节点二叉树,形成一个森林。例如,若有权值集合 {2, 3, 5, 7},则有四棵单节点二叉树。
然后,在森林中选择权值最小的两个节点。比如在上述例子中,先选择权值为 2 和 3 的两个节点。将这两个节点作为新二叉树的左右子节点,新二叉树的根节点权值为这两个子节点权值之和,即 5。
接着,把这个新构建的二叉树放回森林中。此时森林中的树数量减少 1。继续重复上述步骤,即再次选择权值最小的两个树(或节点)进行合并。在这个例子中,森林中剩下权值为 5、5 和 7 的树,选择两个权值为 5 的树合并,新树的根节点权值为 10。
不断重复这个过程,直到森林中只剩下一棵树,这棵树就是哈夫曼树。在构建过程中,权值较小的节点通常在树的较底层,这样能保证带权路径长度最短。例如,对于字符编码应用,出现频率低的字符在哈夫曼树的较底层,其编码长度较长,而出现频率高的字符编码长度较短,从而实现了数据的高效压缩编码。
快排最坏情况发生
快速排序最坏情况主要发生在以下几种情形。
当待排序数组已经是有序(升序或降序)时,如果每次选取的枢轴元素都是当前子数组中的最大或最小元素,就会导致最坏情况。例如,对一个升序排列的数组进行快速排序,若每次都选择第一个元素作为枢轴。第一次划分后,枢轴左边没有元素,右边是除枢轴外的所有元素,子问题规模只减少了 1。
后续对右边的子数组继续划分,每次都选择最左边的元素作为枢轴,这样会导致划分次数达到最大值。时间复杂度从平均的 O (n log n) 退化为 O (n²)。同样,对于降序排列的数组,每次选择最左边元素作为枢轴也会出现最坏情况。
另外,如果数组中的元素全部相等,无论选择哪个元素作为枢轴,也会导致最坏情况。因为每次划分都会得到一个空的子数组和一个包含所有剩余元素的子数组,使得划分次数达到了 n - 1 次,总共的比较次数接近 n²/2,大大增加了排序的时间成本。这种最坏情况在实际应用中需要尽量避免,比如可以通过随机选择枢轴元素等方法来优化快速排序算法。
递归算法对比循环的问题
递归算法和循环算法在多个方面存在不同。
从代码结构上看,递归算法是在函数的定义中使用函数自身的方法。它的优点在于代码简洁、直观,能够清晰地表达问题的求解逻辑。例如,在计算阶乘时,使用递归算法可以写成int factorial(int n) { if (n == 0 || n == 1) return 1; return n * factorial(n - 1); }
,这种方式很容易理解,因为它直接反映了阶乘的数学定义。在处理具有递归结构的数据结构(如树)时非常方便,比如二叉树的前序遍历void preorderTraversal(TreeNode* root) { if (root) { cout << root->val << " "; preorderTraversal(root->left); preorderTraversal(root->right); } }
,代码结构与二叉树的递归定义相匹配。
然而,递归算法存在一些缺点。递归调用会在栈上创建新的栈帧来保存函数的局部变量和返回地址等信息。如果递归深度太深,可能会导致栈溢出。例如,在计算一个非常大的数的阶乘时,可能会耗尽栈空间。而且递归算法在某些情况下可能存在重复计算,降低了效率。比如在计算斐波那契数列时,若使用简单递归方法,会多次重复计算相同的值。
循环算法则是通过迭代的方式来解决问题,通常使用循环结构(如for
、while
)。循环算法的优点是效率高,不会像递归那样存在栈溢出的风险,因为它不需要在栈上保存大量的函数调用信息。例如,使用循环计算阶乘可以写成int factorial(int n) { int result = 1; for (int i = 1; i <= n; i++) { result *= i; } return result; }
。但是,对于一些复杂的问题,尤其是具有递归性质的问题,循环算法的代码可能会变得复杂和难以理解,不像递归算法那样能够直接反映问题的本质。在实际应用中,需要根据具体的问题场景来选择使用递归算法还是循环算法。
优先队列的实现
优先队列是一种特殊的队列,其中每个元素都有一个优先级,出队操作总是移除具有最高(或最低)优先级的元素。
一种常见的实现方式是使用二叉堆。二叉堆可以是最小堆或最大堆。在最小堆中,父节点的值小于或等于子节点的值;在最大堆中,父节点的值大于或等于子节点的值。
对于插入操作,当向堆中插入一个新元素时,先将元素插入到堆的末尾,然后通过向上调整(也称为上浮操作)来维护堆的性质。例如,在最小堆中,如果新插入的元素比它的父节点小,就交换它们的位置,一直重复这个过程直到满足堆的性质。
删除操作(以删除堆顶元素为例),首先将堆顶元素和堆的最后一个元素交换,然后删除最后一个元素。之后,对新的堆顶元素进行向下调整(也称为下沉操作)。在最小堆中,如果堆顶元素大于它的某个子节点,就将它与较小的子节点交换,一直重复这个过程直到满足堆的性质。
另一种实现方式是使用二叉搜索树。二叉搜索树的每个节点都有一个键值,左子树中的键值都小于根节点的键值,右子树中的键值都大于根节点的键值。在优先队列中,可以根据元素的优先级作为键值来构建二叉搜索树。插入操作就是在二叉搜索树中插入新节点,删除操作可以是删除具有最高(或最低)优先级的节点(比如最小键值的节点)。不过,使用二叉搜索树实现的优先队列在最坏情况下的时间复杂度可能不如二叉堆,因为二叉搜索树可能会退化为链表,而二叉堆的插入和删除操作在平均和最坏情况下都能保证较好的时间复杂度。
此外,还有一些基于数组、链表等数据结构的其他实现方式,但它们的性能和复杂度特点各不相同。例如,使用无序数组实现优先队列,插入操作简单(直接在数组末尾添加元素),但删除操作(找到最大或最小元素并删除)效率较低,需要遍历整个数组。
有一个超大文件,无法一次性加载到内存,如何排序
当面对超大文件无法一次性加载到内存进行排序时,可以采用外部排序的方法。
首先,将大文件分割成多个小的、可以加载到内存的块。根据内存的容量确定每个块的大小。例如,若内存可以容纳 1GB 数据,可将文件按照一定规则分成多个约 1GB 大小的子文件。
然后,对每个子文件使用内部排序算法(如快速排序、堆排序等)进行排序。这些内部排序算法在处理较小的数据量时效率较高,可以将每个子文件中的数据排好序。
接下来是归并阶段。可以使用一个较小的缓冲区来实现归并。先将各个已排序子文件的开头部分数据读入缓冲区。例如,每个子文件读入一小部分数据到缓冲区。然后比较缓冲区中各个子文件的数据,选择最小(或最大)的数据写入到一个新的文件中。当缓冲区中的某个子文件的数据被取完后,再从该子文件中读取下一部分数据到缓冲区。
这个过程不断重复,逐步将所有子文件归并成一个排序好的大文件。还可以采用多路归并的方式,同时对多个子文件进行归并,以提高归并的效率。不过,这需要更复杂的缓冲区管理和数据读取策略。
此外,如果有多个磁盘或者存储设备,可以利用它们的并行性来进一步提高排序速度。比如,将子文件分布在不同的磁盘上,同时进行读取和排序操作,然后再将结果进行合并。这种分布式的外部排序方法在处理超大规模文件时能够有效利用系统资源,提高排序效率。
B + 树对比普通树,红黑树的区别,为什么不用 B 树
B + 树与普通树相比,普通树的节点度没有特定限制,可能每个节点的子节点数量差异很大,而 B + 树是一种平衡的多路查找树,节点可以有多个子节点,这种结构使得 B + 树在存储大量数据时,树的高度相对较低。例如,在存储相同数量的数据时,二叉树(一种普通树)可能需要很高的高度,而 B + 树可以通过增加每个节点的子节点数量来降低高度,从而减少磁盘 I/O 次数。
B + 树和红黑树的区别在于,红黑树是一种二叉平衡树,主要用于内存中的数据结构,其平衡操作是为了保证树的高度在对数级别,以实现高效的插入、删除和查找操作,时间复杂度一般为 O (log n)。B + 树更适合用于磁盘等外部存储设备的数据存储和检索。B + 树的叶子节点形成一个有序链表,这使得范围查询非常方便。在 B + 树中,所有的数据都存储在叶子节点,非叶子节点只用于索引,这样可以使树更加紧凑,提高存储效率。
至于为什么不使用 B 树,B 树每个节点都存储数据,这使得节点的大小可能会受到限制,因为节点过大可能无法一次加载到内存。而且 B 树的范围查询没有 B + 树方便,因为 B 树的数据分布在各个节点,不是像 B + 树那样集中在叶子节点。在数据库索引等应用场景中,B + 树的优势更加明显,它能够有效地减少磁盘 I/O 操作,提高数据查询的效率。
哈希:有一大堆重复 int 值,如何获知不同 int 的数量;看你做过爬虫,那你会怎么考虑做网站去重?也就是爬过的 url,不要再爬第二次
对于有一大堆重复 int 值,要获知不同 int 的数量,可以使用哈希表。首先创建一个哈希表数据结构,比如在 C++ 中可以使用unordered_map
。遍历这堆 int 值,对于每个 int 值,尝试在哈希表中查找。如果哈希表中不存在该 int 值对应的键,就将其插入哈希表,并将其对应的值设为 1,表示这个 int 值出现了一次。如果哈希表中已经存在该 int 值对应的键,就将其对应的值加 1。最后,哈希表的大小就是不同 int 值的数量,因为哈希表中的每个键都代表一个不同的 int 值。
在爬虫中对爬过的 url 进行去重,可以使用哈希表来实现。当爬虫准备爬取一个 url 时,先对这个 url 计算哈希值,可以使用常见的哈希函数。将计算得到的哈希值作为键,url 本身或者其他相关信息(如爬取时间等)作为值存储在哈希表中。当遇到一个新的 url 时,同样计算其哈希值,然后在哈希表中查找。如果哈希值已经存在,说明这个 url 很可能已经被爬取过,就可以跳过。如果哈希值不存在,则将这个 url 加入到哈希表中,并进行爬取。不过,使用哈希去重时要注意哈希冲突的问题,可以采用一些方法来减少哈希冲突的影响,如使用高质量的哈希函数,尽量使不同的 url 计算出的哈希值分布均匀。
位图:内存消耗太大,怎么改进;用位图需要提前分配足量内存,假如这些值很稀疏,那就会浪费大量内存,怎么改进?
当位图内存消耗太大时,可以考虑以下改进方法。如果位图用于表示集合元素的存在与否,且元素范围较大但实际存在的元素较少,可以采用压缩位图的方法。例如,使用游程编码来压缩位图,将连续相同的值用一个值和长度来表示。对于位图来说,就是将连续的 0 或者 1 的长度记录下来,这样可以大大减少存储所需的空间。
另外,可以根据数据的实际情况动态分配位图内存。不是一开始就分配整个范围的位图空间,而是随着元素的增加逐步扩展位图。例如,当有新的元素需要在位图中表示时,检查当前位图是否有足够的空间,如果没有,则动态增加位图的大小。
当使用位图且值很稀疏时浪费内存,可以采用分层位图的方法。将位图按照一定的层次进行划分,对于稀疏的部分,可以采用更粗粒度的表示。比如,将一个大的位图分成多个小的子位图,在高层的位图中只记录子位图是否包含非 0 值,然后在需要详细信息的子位图中再进行具体的表示。这样可以在保证一定查询效率的同时,减少内存的浪费。还可以结合其他数据结构,比如对于非常稀疏的位图,可以和哈希表结合,用哈希表来记录存在的元素,而位图只作为一个辅助工具,用于快速判断某个范围是否可能存在元素。
字典树:内存消耗太大,有其他思路吗?
当字典树内存消耗太大时,可以有以下几种思路。一是对字典树进行压缩。对于有大量相同前缀的字符串,可以只存储一次前缀部分,然后通过指针或者其他方式来共享这部分前缀。例如,对于单词 “apple”、“apply” 和 “appetite”,可以只存储一次 “app” 前缀,然后在不同的分支上分别存储 “le”、“ly” 和 “etite”,这样可以减少内存中重复字符串的存储。
可以采用动态字典树的构建方式。不是一次性将所有字符串都插入字典树,而是根据实际的使用频率或者访问顺序来动态调整字典树的结构。例如,对于经常访问的字符串路径,可以保持其在字典树中的节点,而对于很少使用的路径,可以适当进行压缩或者删除。
另外,结合其他数据结构来优化内存使用。比如,对于一些字符串集合,可以先使用哈希表来快速判断某个字符串是否可能在集合中,如果哈希表中存在该字符串,再在字典树中进行更详细的查找。这样可以在一定程度上减少字典树的规模,从而降低内存消耗。同时,可以定期对字典树进行清理,删除一些不再使用或者长时间未访问的节点,释放内存空间。
多线程使用 shared_ptr 如何保护数据安全
在多线程环境中使用shared_ptr
来保护数据安全需要注意几个方面。首先,shared_ptr
本身通过引用计数来管理所指向对象的生命周期。当多个线程同时访问shared_ptr
时,对引用计数的操作是原子性的,这在一定程度上保证了对象不会被过早或错误地释放。然而,仅仅依赖引用计数是不够的。
如果多个线程同时通过shared_ptr
来读写其所指向的对象,就可能出现数据竞争问题。例如,一个线程正在修改对象的某个成员变量,而另一个线程同时也在读取或修改这个成员变量。为了避免这种情况,可以使用互斥锁。在访问shared_ptr
所指向的对象之前,先获取互斥锁,访问完成后再释放锁。这样可以保证同一时刻只有一个线程能够访问对象,确保数据的一致性。
另外,要注意shared_ptr
的循环引用问题。在多线程环境下,这种循环引用可能导致内存泄漏。比如,两个或多个对象通过shared_ptr
相互引用,它们的引用计数永远不会降为 0,即使这些对象已经不再被外部使用。为了避免这种情况,可以使用weak_ptr
来打破循环引用。weak_ptr
可以观察shared_ptr
所指向的对象,但不会增加引用计数,当需要使用对象时,可以通过lock
函数来获取一个有效的shared_ptr
,如果对象已经被释放,lock
函数将返回一个空的shared_ptr
。
条件变量伪唤醒
条件变量的伪唤醒是在多线程编程中一个需要关注的问题。条件变量通常用于线程间的同步,一个线程等待某个条件满足,而另一个线程在条件满足时通知等待的线程。
伪唤醒是指线程在没有真正满足预期条件的情况下被唤醒。这可能是由于操作系统的实现细节、信号处理或者其他未知的原因导致的。例如,在一个生产者 - 消费者模型中,消费者线程等待队列中有数据的条件变量。但由于伪唤醒,消费者线程可能在队列仍然为空的情况下被唤醒。
为了应对伪唤醒,在使用条件变量时,不能仅仅依赖被唤醒这一事件,还需要在被唤醒后重新检查条件是否真正满足。通常是在一个循环中使用条件变量。比如在消费者线程中,当被唤醒后,再次检查队列是否有数据,如果没有数据,继续等待。这样可以避免因为伪唤醒而导致程序出现错误的行为,确保程序的正确性和稳定性。
unique_ptr 转移所有权
unique_ptr
是一种独占式智能指针,它的主要特点是在同一时刻只有一个unique_ptr
可以拥有所指向的对象,这实现了对象所有权的唯一性。
当需要转移unique_ptr
的所有权时,可以通过移动语义来实现。例如,在函数返回unique_ptr
时,会自动发生所有权的转移。假设我们有一个函数std::unique_ptr<SomeClass> createObject() { return std::make_unique<SomeClass>(); }
,当在其他地方调用这个函数时,返回的unique_ptr
的所有权就从函数内部转移到了调用者。
也可以通过显式的std::move
操作来转移所有权。比如有std::unique_ptr<AnotherClass> ptr1 = std::make_unique<AnotherClass>();
和std::unique_ptr<AnotherClass> ptr2 = std::move(ptr1);
,这里通过std::move
将ptr1
的所有权转移给了ptr2
,之后ptr1
不再拥有对象,而ptr2
成为对象的唯一拥有者。这种所有权转移机制可以有效地避免内存管理问题,如对象的意外共享或多次释放,同时也方便了对象在不同作用域之间的传递。
move 的实现方式
move
操作的实现主要是利用了右值引用和移动语义。在 C++ 中,右值引用是通过&&
来表示的。当对一个对象使用std::move
时,实际上是将其转换为一个右值引用。
从底层实现来看,std::move
并没有真正地移动对象的数据,它只是一种类型转换,将左值强制转换为右值引用。对于一些支持移动语义的类型(如std::string
、std::vector
等),它们内部实现了移动构造函数和移动赋值运算符。以std::string
为例,当通过std::move
将一个std::string
对象转换为右值引用后,如果进行复制操作(如函数参数传递或赋值),编译器会优先调用移动构造函数或移动赋值运算符,而不是常规的复制构造函数或复制赋值运算符。
在移动构造函数和移动赋值运算符中,实现了对对象资源的有效转移。比如对于std::vector
,移动构造函数可能会直接将原vector
中的指针、大小和容量等信息复制过来,而不需要像复制构造函数那样逐个复制元素,从而提高了性能。这种移动语义的实现方式使得在处理临时对象或者不再需要的对象时,可以避免不必要的资源复制,提高程序的效率。
完美转发的作用
完美转发在 C++ 模板编程中有着重要的作用。它能够在函数模板中保持参数的值类别(左值或右值)和类型信息不变地传递给另一个函数。
当我们有一个函数模板,它接收参数并将这些参数转发给另一个函数时,如果没有完美转发,可能会导致参数的类型和值类别信息丢失。例如,在一个函数模板中,如果直接将参数传递给另一个函数,可能会把原本的右值参数当作左值处理,这会导致不必要的复制。
完美转发通过std::forward
函数实现。它允许我们编写通用的函数模板,这些模板可以正确地处理各种类型和值类别的参数。比如在一个包装函数中,它可能会接收不同类型的参数,包括临时对象(右值)和已命名的对象(左值),通过完美转发,可以确保这些参数以原始的状态传递给被调用的函数。这在实现代理函数、工厂函数或者其他通用的函数包装器时非常有用,可以保证函数调用的语义与原始参数的语义一致,提高了代码的通用性和灵活性,同时避免了因参数传递不当而导致的性能问题。
进程、线程、协程对比(应用场景、效率)
应用场景:
-
进程:进程独立性强,适合运行多个独立的程序。比如在服务器上同时运行 Web 服务器进程、数据库管理进程和邮件服务进程等,各个进程互不干扰,一个进程崩溃不会影响其他进程。在需要资源隔离的场景中表现出色,例如在云计算环境中,不同用户的应用程序可以运行在不同的进程中,保障用户间的资源和数据隔离。此外,进程还常用于需要长期稳定运行且功能相对独立的大型应用,每个进程负责一个特定的功能模块。
-
线程:在多任务处理且任务之间有共享数据的场景中广泛应用。例如在网络服务器中,多个线程可同时处理不同客户端的请求,并且这些线程能共享服务器的缓存、数据库连接等资源。在图形用户界面(GUI)应用中,一个线程可以负责接收用户的输入,另一个线程负责更新界面显示,以此提高程序的响应速度。对于那些需要频繁切换执行路径的任务,线程也能很好地胜任,因为线程间的切换相对较快。
-
协程:适用于高并发的 I/O 密集型任务。例如在网络爬虫程序中,协程可以高效地处理大量的网络请求和数据解析任务。当遇到 I/O 阻塞(如等待网络响应)时,协程可以暂停执行,将执行权交给其他协程,从而提高程序的整体效率。在异步编程中发挥重要作用,它可以和异步 I/O 库结合使用,使代码在逻辑上更像同步代码,但实际上是异步执行的,这样能提高代码的可读性和可维护性。同时,协程在实现轻量级的并发任务时优势明显,因为其创建和切换的开销极小。
效率:
-
进程:进程的创建和销毁开销较大。创建一个进程需要分配独立的地址空间、初始化各种系统资源等,这可能涉及大量的系统调用。例如在 Linux 系统下,fork 系统调用会复制父进程的大部分资源来创建一个子进程,这个过程复杂且耗时。进程间通信(IPC)相对复杂且效率较低,因为进程有独立的地址空间,进行通信时需要通过共享内存、管道、消息队列等方式,这些方式都有一定的开销,并且需要考虑同步和互斥等问题。不过,由于进程独立性强,在多核系统中可以充分利用多核资源,不同进程可以并行运行在不同的核心上。
-
线程:线程的创建和销毁成本比进程低。因为线程共享进程的地址空间,创建线程时只需要分配线程相关的资源,如线程栈等,不需要像进程那样复制整个地址空间。线程间通信相对简单且高效,由于线程共享进程的地址空间,它们可以直接访问共享变量,但这也容易引发数据安全问题,需要通过互斥锁、信号量等机制来保证数据的同步和互斥。然而,线程的切换还是需要一定的系统开销,包括保存和恢复线程的上下文信息等。在多核系统中,多线程可以实现并行执行,提高程序的运行效率。
-
协程:协程的切换成本非常低。协程的切换不需要像线程切换那样涉及系统调用和大量的上下文切换,它是在用户空间内由程序自己控制切换,通常只需要保存和恢复少量的寄存器和栈信息。在处理 I/O 密集型任务时效率很高,因为它可以在等待 I/O 操作完成时主动让出执行权,避免了线程或进程在等待 I/O 时的空闲浪费,使得程序能够高效地利用 CPU 资源。但协程在计算密集型任务中的优势不明显,因为它不能利用多核并行计算,同一时刻只有一个协程在执行。
C++ 关键字 inline
在 C++ 中,inline
关键字主要用于函数定义,它对编译器是一种建议,请求编译器将函数的代码直接嵌入到调用该函数的地方,而不是进行常规的函数调用。
对于一些简单的、频繁被调用的函数,使用inline
关键字可以带来性能上的提升。例如,有一个简单的函数用于计算两个整数的和,像inline int add(int a, int b) { return a + b; }
。如果在一个循环中频繁调用这个函数,当编译器将函数代码嵌入到调用处时,就不需要每次都进行函数调用的准备工作,如保存函数的返回地址、参数传递等。这样可以减少函数调用的开销,提高程序的执行效率。
然而,编译器有权决定是否真正将函数内联。编译器会考虑函数的复杂性、调用频率、代码大小等因素。如果函数体比较复杂,包含了循环、递归或者大量的代码,编译器可能不会将其内联。因为将复杂函数内联可能会导致代码膨胀,增加内存占用,反而降低程序的性能。
另外,inline
函数的定义通常需要放在头文件中。这是因为编译器在编译每个源文件时,需要知道inline
函数的完整定义,才能将其嵌入到调用处。如果只在头文件中声明inline
函数,而在源文件中定义,编译器可能无法正确地内联函数。在类的定义中,成员函数可以默认被看作是inline
函数。例如,在一个简单的类定义class MyClass { public: int getValue() { return value; } private: int value; };
中,getValue
函数可以被看作是inline
函数,这使得在类的对象频繁调用这个成员函数时,能够提高效率。
C++ 类与结构的区别
在 C++ 中,类(class)和结构(struct)有相似之处,但也存在一些区别。
从语法层面看,定义类和结构的方式很相似。例如,定义一个类可以是class MyClass { public: int value; void setValue(int v) { value = v; } };
,定义一个结构可以是struct MyStruct { int value; void setValue(int v) { value = v; } };
,它们都可以包含成员变量和成员函数。
但在默认的访问控制权限上有区别。在类中,默认的访问控制是private
,这意味着如果没有明确指定访问权限,类的成员变量和成员函数只能在类的内部访问。而在结构中,默认的访问控制是public
,结构的成员变量和成员函数在默认情况下可以从外部访问。例如,对于上述的MyClass
和MyStruct
,在类MyClass
中,value
成员变量和setValue
函数如果没有指定public
权限,外部代码无法直接访问;而在结构MyStruct
中,外部代码可以直接访问value
成员变量和setValue
函数。
在语义上,类通常用于表示具有复杂行为和状态的抽象概念。比如一个图形类可以包含图形的属性(如颜色、形状等)和操作图形的方法(如绘制、旋转等),类中的函数可以对类的成员变量进行复杂的操作和逻辑处理。而结构更多地用于简单的数据组合,比如表示一个点的坐标结构struct Point { int x; int y; };
,它主要是将相关的数据组合在一起,方便传递和处理,一般不包含复杂的行为。
在继承方面,类支持继承,并且可以通过访问控制符(public
、private
、protected
)来控制继承的方式。结构也支持继承,但是在实际应用中,结构继承相对较少使用,并且在继承时默认是public
继承,这与类的默认private
继承不同。当使用结构继承时,其目的往往更侧重于数据的组合和延续,而类继承更多地涉及到行为和状态的扩展与修改。
C++explicit 关键字
在 C++ 中,explicit
关键字主要用于构造函数。当一个构造函数被声明为explicit
时,它不能用于隐式转换。
例如,考虑一个类class MyClass { public: MyClass(int value) { this->value = value; } private: int value; };
,如果没有explicit
关键字,在某些情况下可以进行隐式转换。比如int num = 5; MyClass obj = num;
,编译器会自动将num
转换为MyClass
的对象,这是通过调用MyClass
的构造函数来实现的。
这种隐式转换在某些情况下可能会导致意外的结果。而将构造函数声明为explicit
,即explicit MyClass(int value) { this->value = value; }
,就可以避免这种意外的隐式转换。这一特性在很多场景中有重要意义。
比如在函数参数传递中,如果一个函数接受MyClass
类型的参数,没有explicit
关键字时,可能会出现错误的类型匹配。假设函数void func(MyClass obj) {... }
,如果没有explicit
关键字,当调用func(5);
时,编译器会自动将5
转换为MyClass
对象,这可能并不是程序员的本意。使用explicit
关键字可以使代码更加清晰和安全,明确地表明类型转换的意图。
在复杂的类层次结构和模板编程中,explicit
关键字也有助于避免不必要的类型转换和模糊性。它可以确保只有显式的构造函数调用才会创建对象,提高代码的可读性和可维护性,减少因隐式转换导致的错误。
野指针与悬挂指针的区别
野指针和悬挂指针都是 C++ 编程中可能导致错误的指针问题,但它们有一些区别。
野指针是指指针变量未被初始化或者被释放后没有置空,从而其指向的内存地址是不确定的。这种指针可能指向任何地方,包括系统保留的内存区域或者其他正在使用的内存。例如,int * wildPtr;
这里wildPtr
没有被初始化,它就是一个野指针。如果后续对这个野指针进行解引用操作(如*wildPtr = 10;
),可能会导致程序崩溃或者出现不可预测的行为,因为它可能修改了不该修改的内存内容。野指针的危险性在于它的不确定性,可能会破坏系统的稳定性或者导致数据损坏。
悬挂指针则是指针原本指向的内存已经被释放,但指针仍然存在。例如,int * ptr = new int(5);
然后delete ptr;
,此时ptr
就变成了悬挂指针。如果之后不小心再次使用ptr
(如*ptr = 10;
),这是一种错误的操作,因为ptr
所指向的内存已经被归还给系统,再次访问可能会导致程序崩溃或者出现未定义行为。悬挂指针的问题在于它指向的内存已经不存在,对其进行操作就像在访问不存在的资源。
在编程中,要避免野指针可以通过在定义指针时就将其初始化为nullptr
或者指向合法的内存地址。对于悬挂指针,要确保在释放内存后不再使用相应的指针,可以将指针置为nullptr
或者将其作用域限制在内存有效的范围内,比如在智能指针的使用中可以更好地管理指针的生命周期,减少这些问题的出现。
智能指针介绍
智能指针是 C++ 中用于自动管理动态分配内存的一种机制,它能够有效地避免内存泄漏和悬空指针等问题。
首先是std::unique_ptr
,它是一种独占式智能指针。这意味着在同一时刻,只有一个unique_ptr
可以拥有所指向的对象。当unique_ptr
被销毁时,它所指向的对象也会自动被删除。例如,在函数内部创建一个unique_ptr
来管理动态分配的资源,当函数结束时,unique_ptr
的析构函数会自动释放资源。这种独占性使得资源的所有权清晰明确,并且通过移动语义可以在不同作用域之间传递对象的所有权。比如,将一个unique_ptr
从一个函数返回给另一个函数,通过std::move
操作实现所有权的转移。
std::shared_ptr
是一种共享式智能指针。多个shared_ptr
可以同时指向同一个对象,它通过引用计数来管理对象的生命周期。每当一个新的shared_ptr
指向对象时,引用计数就会增加;当一个shared_ptr
被销毁或者不再指向对象时,引用计数就会减少。当引用计数为 0 时,对象才会被自动删除。这种机制在多个对象需要共享同一份资源的场景中非常有用。例如,在一个树形数据结构中,多个节点可能需要共享同一份数据,就可以使用shared_ptr
来管理。不过,要注意shared_ptr
可能会出现循环引用的问题,这会导致内存泄漏。为了解决这个问题,可以使用std::weak_ptr
,它可以观察shared_ptr
所指向的对象,但不会增加引用计数。
还有std::weak_ptr
,它主要用于解决shared_ptr
的循环引用问题。当weak_ptr
需要访问对象时,可以通过lock
函数来获取一个有效的shared_ptr
,如果对象已经被释放,lock
函数将返回一个空的shared_ptr
。这使得可以在不影响对象生命周期的情况下,对对象进行观察和访问。
智能指针使得 C++ 中的内存管理更加安全和方便,减少了程序员手动管理内存的负担,提高了程序的可靠性。
C++ 内存情况和内存分配
在 C++ 中,内存主要分为几个不同的区域。
首先是栈区,它是由编译器自动分配和释放的内存区域。当函数被调用时,函数的参数、局部变量等会在栈区分配内存。栈区的内存分配和释放遵循后进先出(LIFO)的原则。例如,在一个函数内部定义了多个局部变量,当函数执行结束时,这些局部变量的内存会按照定义的相反顺序依次被释放。栈区的优点是效率高,因为它的分配和释放操作非常简单,只需要移动栈指针即可。但是栈区的大小是有限制的,在不同的系统和编译器下可能有所不同,如果栈空间不足,可能会导致栈溢出。
堆区是用于动态内存分配的区域。程序员可以通过new
和delete
运算符(在 C++ 中)或者malloc
和free
函数(在 C 和 C++ 兼容的情况下)来在堆区分配和释放内存。例如,int *ptr = new int;
就是在堆区分配了一个int
类型大小的内存空间。堆区的优点是可以根据程序的需要灵活地分配任意大小的内存。然而,堆区的内存管理相对复杂,需要程序员手动释放内存,如果忘记释放内存,就会导致内存泄漏。而且,频繁地在堆区进行内存分配和释放操作可能会产生内存碎片,影响内存的利用率。
还有全局区(静态区),这个区域用于存储全局变量和静态变量。全局变量在整个程序的生命周期内都存在,静态变量根据其类型(静态局部变量或静态全局变量)有不同的作用域,但它们的内存都是在全局区分配的。例如,一个全局变量在程序启动时就会被分配内存,并且在程序结束时才会被释放。
另外,代码区用于存储程序的可执行代码,这部分内存是只读的,程序在运行过程中不能修改代码区的内容。
在内存分配方面,要根据具体的需求选择合适的内存区域。对于临时的、局部的变量,使用栈区比较合适;对于需要长期保存、动态分配的资源,使用堆区并配合正确的内存管理机制(如智能指针)是比较好的选择。
C++ 编译过程和链接过程
C++ 的编译过程主要包括预处理、编译、汇编和链接这几个阶段。
预处理阶段是编译的第一步,这个阶段主要是处理以#
开头的预处理指令。例如,#include
指令会将指定的头文件内容包含到当前文件中,这使得代码可以使用头文件中声明的函数、类等。#define
指令用于定义宏,它可以进行简单的文本替换。在预处理阶段,编译器会展开这些宏,并且处理条件编译指令(如#ifdef
、#ifndef
等),根据条件决定是否编译某些代码块。这个阶段的输出是一个没有预处理指令的纯 C++ 代码文件。
编译阶段是将预处理后的 C++ 代码转换为汇编语言的过程。编译器会对 C++ 代码进行词法分析、语法分析和语义分析。词法分析是将代码分解为一个个的单词(如标识符、关键字、运算符等);语法分析是检查代码是否符合 C++ 的语法规则;语义分析则是检查代码的语义是否正确,例如检查变量是否在使用前被定义、函数调用的参数是否正确等。在这个过程中,编译器会根据 C++ 的语法和语义规则生成相应的中间表示(如抽象语法树),然后将中间表示转换为汇编语言。
汇编阶段是将汇编语言转换为机器语言的过程。汇编器会将汇编语言指令转换为机器可以执行的二进制指令,生成目标文件。目标文件包含了机器语言代码、数据和一些符号信息,这些符号信息用于链接阶段。
链接阶段是将多个目标文件以及可能需要的库文件组合在一起,生成可执行文件的过程。链接器会解决目标文件之间的符号引用问题。例如,一个目标文件中的函数调用了另一个目标文件中的函数,链接器会将这两个函数的地址关联起来。同时,链接器还会处理库文件的链接,将程序所需要的库函数的代码添加到可执行文件中。如果在链接过程中发现有未定义的符号,就会产生链接错误。
Qt 中一些类构造函数的指针参数
在 Qt 中,许多类的构造函数带有指针参数,这些指针参数有着不同的用途。
以QWidget
类为例,它是 Qt 中用于创建用户界面组件的基础类。它的构造函数可以接受一个指向父窗口的指针作为参数。例如,QWidget *parentWidget; QWidget myWidget(parentWidget);
,这里的parentWidget
指针参数用于指定myWidget
的父窗口。这种父子关系在 Qt 的界面布局和对象管理中有重要作用。当父窗口被销毁时,它的子窗口也会自动被销毁,这有助于管理界面组件的生命周期,避免内存泄漏。
对于QObject
类(QWidget
是QObject
的子类),它的构造函数也可以接受一个父对象指针。这个指针参数不仅用于管理对象的生命周期,还用于信号槽机制的连接。在信号槽机制中,对象之间通过信号和槽进行通信,而父对象指针可以帮助确定信号和槽的连接范围和顺序。
在一些数据处理相关的类中,比如QByteArray
,它的构造函数可以接受一个指向字节数组数据的指针和数据长度作为参数。例如,const char *data; int length; QByteArray byteArray(data, length);
,通过这种方式,可以方便地将外部的字节数组数据封装到QByteArray
对象中,用于后续的处理,如数据传输、加密等操作。这些指针参数在 Qt 的各种应用场景中发挥着关键作用,使得类能够更好地与外部数据和其他对象进行交互。
Qt 常用控件
Qt 提供了丰富的常用控件来构建用户界面。
QPushButton
是一种按钮控件,它是最基本的用户交互控件之一。用户可以通过点击按钮来触发相应的操作。可以设置按钮的文本、图标、大小等属性。例如,在一个简单的登录界面中,有 “登录” 和 “取消” 按钮,这些按钮可以通过QPushButton
来实现。可以为按钮连接信号槽,当按钮被点击时,发射clicked
信号,通过连接到相应的槽函数来执行登录或取消操作。
QLineEdit
是单行文本编辑控件,用于用户输入和编辑单行文本。可以设置文本的显示格式、限制输入的类型(如只允许输入数字)等。在一个注册界面中,用户可以使用QLineEdit
来输入用户名、密码等信息。它还支持一些验证功能,如输入掩码,可以确保用户输入符合特定的格式要求。
QTextEdit
是多行文本编辑控件,与QLineEdit
相比,它可以处理多行文本内容。可以用于显示和编辑大量的文本,如文章内容、日志信息等。可以设置文本的字体、颜色、对齐方式等格式。在一个文档编辑应用中,QTextEdit
是核心的控件之一,用户可以在其中撰写和修改文档内容。
QComboBox
是组合框控件,它结合了下拉列表和可编辑文本框的功能。用户可以从下拉列表中选择一个选项,也可以在文本框中输入自定义的内容。例如,在一个设置界面中,用于选择语言、字体等选项时,可以使用QComboBox
。它的下拉列表中的选项可以通过代码动态添加或删除,以适应不同的应用场景。
QListView
和QTreeView
是用于显示列表和树形结构数据的控件。QListView
适用于简单的线性列表数据展示,如文件列表、联系人列表等。QTreeView
则更适合用于展示具有层次结构的数据,如文件系统的目录结构、组织架构等。这些控件可以通过数据模型(如QAbstractItemModel
的子类)来提供数据,并且可以自定义数据的显示方式和用户交互方式。