当前位置: 首页 > news >正文

续啃《编程指北 C++》智能指针(牵扯无穷无尽的其他知识)

C++ 智能指针解析

扩展出了无穷无尽的知识点:

智能指针从懵逼到精通手写模拟板智能指针(学到了模版、lambda、删除器等所有涉及到的东西)、

vector、

迭代器涉及到的所有相关

中途遇到排序的比较函数,又顺着这条线,去花了大量时间搞精通了几乎所有排序的:数组版本、单链表版本、递归版本、非递归版本

无穷无尽的填补缺漏大窟窿,写不过来了...

好奇统计了下几篇博客的文字,wx 搜“文字统计”。

《续啃:程指北 C++ (从 RAII开始的,RAII这一小节,学了将近两个月,RAII 的内容早在之前就主动追问豆包搞懂了,这节主要是自己无尽追问探索出很多其他知识,后来发现其实堪比精啃 CSAPP & APUE 等圣书) 》:word 统计共计 218345 字

《编程指北的 C++ 》:word 统计共计 375794 字

《续:啃操作系统 》:word 文字统计共计 385318 字

100w 字的博客啊!没任何水的成分!

这还没算刷邝斌算法五大专题

96年,二本计算机,19年大学毕业考研,期间爸爸有病,一直在家附近做了 3 年社会零工,23 年第一次出来真正找工作,0基础培训做银行外包测试,做了一年,被业务老师劝退,被当成傻逼怪人,总思考各种问题,想优化银行系统,发现银行大家测试的东西基本很多都是错的没有任何意义,得过且过,完全是只重数量不重质量,所有人都待的很舒服,只有我活不下去。北京 - 西安 - 乌鲁木齐,真诚到极致,最后被做事阴险、表面一套背后一套的组长搞走。那些副嘴脸,人的百面,呵呵!真是有趣。24年5月辞职一直自学至今。

起手通过刷算法找到学习节奏,进入学习状态,最主要的是有了极大的自信和信心,代码能力有了质的飞跃,那时候还不知道大模型,思考反而深邃深刻。由于 POJ 总崩,每次刷题都先找到同源题,hdoj、POJ、洛谷高中oi平台,基本全都是想破脑袋自己想出来然后 AC 的,一道题想 + 改最少要 3 天,没思路的题基本只有零星的几道,看不到任何后台数据,过不了就对拍、硬想,每道题 AC 后觉得自己的方法笨,又看别人的做法一题多解,然后很多博客无法 AC 又给他们的代码改 bug 改 AC,然后知道每道题不同平台后台数据的强弱和坑点。最后感觉纯网瘾,学到 AC 自动机就停了,开始学 C++菜鸟教材一路下来。

无数次的心灰意冷,每次绝望都能立马鼓气。

各种钻研不考的东西(之前博客有提及,wx搜),每个知识点都事无巨细的研究、无尽挖掘,自己写代码实验思考,无尽延展,然后后期发现不同编译器结果又不同。

相比于技术,成长最大的是心性和天赋的挖掘、意志力的磨砺、人情世故、待人接物、为人处事,不破不立破而后立逆天改命大彻大悟,看透社会的底层运转法则运转规律,人真的是环境的产物,绝境可以塑造一个人,有了引以为傲的社会经历和阅历(之前博客也有提及,wx搜)

bb割肾、造瘘、2年一次的手术,无尽的折磨,bb的病再也无法治好了,相比之下 C++ 真的就那么难吗?只要学一份就有一份收获,无尽追问豆包,哪怕经常误人子弟但无尽砸时间总会问出结果,不会把我的路堵死,而不是到处求医“没有治疗方案”。想比之下,学知识真的是再简单不过的事,至少自己可以掌控结果。

罗斯纪录片、黄国平致谢论文、艾弗森纪录片、当幸福来敲门。

从底层的垃圾烂泥堆里,挣扎着生生爬出来。

一无是处穷途末路,何时有我的出头之日啊~~~~(>_<)~~~~

还没算自认为傻逼过时很鸡肋的后悔逐字精啃了3个月的《菜鸟教程》、

还没算《项目:从零开始做一个HTTP服务器(准备篇 —— 无尽弯路错路》那篇。

所有硬核知识点都博客里,但标题不是实时更新的,比如:

  • 单单标题为《项目:从零开始做一个HTTP服务器(准备篇 —— 无尽弯路错路》的那篇,就包括了精啃《TCPIP网络编程》尹圣雨 + 小林coding,且给他们都写了勘误。

  • 单单标题为《续:啃操作系统 》的那篇,就包括小林 coding、手写迭代 7 个版本的多线程服务器项目。

一路尸山血海、踩过的所有坑、所有感悟、永无止境无穷无尽的钻研追问海厚海厚的知识点、点点滴滴的心路历程全有痕迹。(其中 80% 的经历时间都是在研究不考的、错误的东西,但一意孤行真的觉得有收获,自认为精通了很多底层知识)

一般写代码出现的:

1、SegmentFault(段错误)

  • 核心定义:程序访问了不属于自己的内存区域(比如空指针、越界数组、读写只读内存),操作系统直接终止程序并抛出的错误(Linux 下终端会显示 Segmentation fault

  • 对你的价值:你写 HTTP 服务器时,多线程访问共享内存越界、Socket 描述符操作不当、指针未初始化就解引用,都会触发这个错误,是服务端开发最常见的崩溃原因。

2、Core Dump(核心转储)

  • 核心定义:程序触发 SegmentFault 等致命错误时,操作系统把程序崩溃瞬间的内存、寄存器状态保存成的文件(默认可能关闭,需手动开启)。

  • 对你的价值:用 gdb 分析这个文件,能精准定位崩溃的代码行(比如是哪个线程、哪行指针操作出问题),是排查你服务器崩溃的核心工具。
// 单指针释放(无多余括号,实战写法)
int* p = new int(10);
delete p;   // 直接释放,判空可省略(delete nullptr是安全的)
p = nullptr;// 必须置空,避免悬垂指针// 数组指针释放(实战写法)
int* arr = new int[5];
delete[] arr;
arr = nullptr;

delete p:释放指针指向的堆内存,但p本身仍存原地址会成指向已释放内存的指针,会段错误。

科普:close -1报错,delete null安全。

p = nullptr:清空指针值,避免后续误操作(如重复释放、解引用野指针)

程序结束后操作系统会回收进程所有资源(包括没释放的堆内存),属于操作系统层面的 “兜底释放”。

但 RAII 的意义是在程序运行中就保证内存及时释放,而非依赖程序结束的兜底,属于 C++ 代码层面的 “内存管理”。 

正常写个 RAII 自动释放指针:

查看代码
#include <iostream>
class RAII_Ptr {
private:int* ptr; 
public:// 构造:获取资源(RAII第一步:初始化时拿资源)explicit RAII_Ptr(int val) : ptr(new int(val)) {}// 析构:释放资源(RAII第二步:销毁时自动放资源)~RAII_Ptr() {delete ptr; // 这里不用判空!C++标准规定delete nullptr是安全的ptr = nullptr;}// 禁止拷贝:避免浅拷贝导致重复释放(核心!保证独占所有权)RAII_Ptr(const RAII_Ptr&) = delete;RAII_Ptr& operator=(const RAII_Ptr&) = delete;// 允许移动:转移资源所有权(符合RAII的资源管理逻辑)RAII_Ptr(RAII_Ptr&& other) noexcept : ptr(other.ptr) {other.ptr = nullptr;}RAII_Ptr& operator=(RAII_Ptr&& other) noexcept {if (this != &other) {delete ptr;ptr = other.ptr;other.ptr = nullptr;}return *this;}// 模拟指针操作(非核心,只是方便使用)int& operator*() { return *ptr; }int* operator->() { return ptr; }
};int main() {RAII_Ptr p(10);std::cout << *p << std::endl; // 输出10// 函数结束,p析构,自动delete,无内存泄漏
}

Q:模拟指针操作那两句干啥的?

A:

1、int& operator*() { return *ptr; } —— 重载 * 解引用符。如果没有这行,你想访问 RAII_Ptr 管理的内存里的值,得写很别扭的代码;加了这行,就能像用普通指针一样用 *

// 没有重载*的情况(假设没写这行):
// 你得给RAII_Ptr加个get()方法才能拿到ptr,再解引用
class RAII_Ptr {
public:int* get() { return ptr; } // 额外加的方法// ...其他代码不变
};
RAII_Ptr p(10);
std::cout << *(p.get()) << std::endl; // 别扭的写法// 有重载*的情况(你代码里的写法):
RAII_Ptr p(10);
std::cout << *p << std::endl; // 直接写*p,和普通指针用法完全一样,输出10

int* get() { return ptr; } 是在类中定义的一个成员函数,语法上属于 “无参数、返回值为 int 类型指针的公有成员函数”,作用是对外暴露类内封装的原始指针ptr

核心语法:类的公有成员函数,返回值为int*,无入参,函数体返回私有成员ptr

核心作用:突破封装,让外部能获取到类内管理的原始指针(智能指针也有get()方法,逻辑完全一致)。

类似定义了个符号,get() 就是个快捷调用的函数名,核心用法就一句话:用对象点(.)调用这个函数,直接拿到类里封装的原始指针:

RAII_Ptr p(10);    // 创建对象
int* raw_ptr = p.get(); // 用对象p调用get(),拿到内部的ptr(原始int*指针)
2. int* operator->() { return ptr; } —— 重载 -> 成员访问符,这行主要是为了适配 “指针指向有成员的对象”(比如结构体 / 类),你代码里因为管理的是 int*(int 没有成员),所以这行只是 “演示”,但如果管理自定义类型就必须有:
// 举个例子:管理有成员的结构体
struct Person {int age;std::string name;
};// 假设RAII_Ptr适配Person*(改下模板就行),有了->重载:
RAII_Ptr<Person> p(new Person{20, "张三"});
std::cout << p->age << std::endl; // 直接用->访问成员,输出20
std::cout << p->name << std::endl; // 输出张三// 没有->重载的话,得写:
std::cout << (*p).age << std::endl; // 又绕又麻烦

总结:

  • operator*()重载解引用符:让 RAII_Ptr 对象能像普通指针一样用 *对象 解引用,拿到指针指向的完整对象 / 值。比如 *p 拿到 int 值、*p 拿到 Person 对象

  • operator->()重载成员访问符:让 RAII_Ptr 对象能像普通指针一样用 对象->成员 访问指针指向对象的成员(比如 p->age 直接拿 Person 对象的 age 成员)。-> 重载会自动帮你完成解引用 + 访问成员的操作,不用手动先写 *

至此说了两个重载和一个别扭的get,而智能指针unique_ptr/shared_ptr不仅重载了这两个操作符还有get方法,所以你用 std::unique_ptr<int> up(new int(20)); int* p = up.get(); 时,才能直接写:

// *p:重载*操作符,直接解引用拿值(和普通指针*p一样)
int val1 = *p; // val1 = 10// p.get():调用get()拿原始指针,再解引用拿值(备用方式) 
int val2 = *(p.get()); // val2 = 10

Q:可是get没必要吧??直接*就行啊!

A:不是没必要!get()是为了获取原始指针,应对必须用裸指针的场景(比如调用 C 接口),而*只能拿值 / 对象,拿不到原始指针本身。

* 重载:只能解引用获取指针指向的值 / 对象,拿不到原始指针变量;

get() 方法:专门返回封装的原始裸指针,用于必须传入裸指针的场景(如系统调用、C 语言接口),是*无法替代的。

至此懂了 RAII 手写模拟智能指针,但:

一、自己写的只能管一种类型,通用性差:

自己写的RAII_Ptr只能管理int*,如果想管理double*char*或者你服务器里的HttpRequest*,就得重新写一个几乎一样的类,重复劳动。而智能指针是模板类(T),则可以适配所有类型(int、double、管理数组需要 delete []):
// 你的RAII_Ptr:管理数组直接内存泄漏+未定义行为
int* arr = new int[5]{1,2,3,4,5};
// RAII_Ptr p(arr); // 首先构造函数不接收指针,其次析构是delete不是delete[],会崩溃// 智能指针:专门适配数组,自动用delete[]释放
std::unique_ptr<int[]> up_arr(new int[5]{1,2,3,4,5});
// 析构时自动执行delete[] up_arr.get(),无需改代码

二、智能指针内置了 “服务端开发必用” 的扩展功能

你现在学的是基础版,实际做 Linux C++ 服务端时,会遇到 “释放数组(需要delete[])”“释放 Socket(需要close())” 这类场景,智能指针直接支持,你自己写的话要改析构,麻烦:

// 你的RAII_Ptr:析构只有delete,无法执行close,Socket描述符会泄漏
int sock_fd = socket(AF_INET, SOCK_STREAM, 0);
// RAII_Ptr p(sock_fd); // 构造函数不匹配,且析构不会close(fd)// 智能指针管理Socket描述符,自动close,你写的RAII_Ptr要重写析构因为Socket 描述符需要close而不是delete
//第二个模板参数是“删除器类型”,lambda里写自定义释放逻辑
std::unique_ptr<int, void(*)(int*)> sock(new int(socket(AF_INET, SOCK_STREAM, 0)),[](int* fd) { close(*fd); delete fd; });// 自定义析构逻辑:先close,再释放fd本身
// 析构时自动执行这个lambda,完美适配Socket释放
总结:
  • 智能指针 = 官方封装好的 “通用版 RAII 指针”,核心逻辑和你写的完全一样;

  • 实际开发用智能指针,只是因为它通用、能适配更多场景,不用自己重复写代码。

在 C++中,智能指针常用的主要是两个类实现:

结合上面的短板,改造代码,支持 “通用类型 + 自定义删除器” 的迷你版unique_ptr(涉及到模板声明 / 实例化、成员变量 / 函数定义、构造 / 析构函数、=delete 禁用、右值引用与移动语义、操作符重载、lambda 表达式、函数指针、堆内存分配):

查看代码
#include <iostream>
#include <functional> // 用于function(删除器类型)// 模板类:适配任意类型T + 自定义删除器
template <typename T, typename Deleter = std::default_delete<T>>
class MySmartPtr {
private:T* ptr;Deleter del; // 存储自定义删除器(默认是std::default_delete,即delete)public:// 构造:接收指针+删除器(删除器默认)explicit MySmartPtr(T* p = nullptr, Deleter d = Deleter{}) : ptr(p), del(d) {}// 析构:调用删除器,不再硬编码delete~MySmartPtr() {if (ptr) del(ptr); // 用删除器释放资源,而不是固定deleteptr = nullptr;}// 禁止拷贝(和你原来的逻辑一致)MySmartPtr(const MySmartPtr&) = delete;MySmartPtr& operator=(const MySmartPtr&) = delete;// 允许移动(和你原来的逻辑一致)MySmartPtr(MySmartPtr&& other) noexcept : ptr(other.ptr), del(std::move(other.del)) {other.ptr = nullptr;}MySmartPtr& operator=(MySmartPtr&& other) noexcept {if (this != &other) {if (ptr) del(ptr); // 先释放当前资源ptr = other.ptr;del = std::move(other.del);other.ptr = nullptr;}return *this;}// 重载*和->(和你原来的逻辑一致,只是换成T)T& operator*() { return *ptr; }T* operator->() { return ptr; }// 加get()方法(和智能指针一致)T* get() { return ptr; }
};// 测试:复刻智能指针的核心用法
int main() {// 1. 管理普通int*(默认删除器,即delete)MySmartPtr<int> p1(new int(10));std::cout << *p1 << std::endl; // 输出10// 2. 管理数组(自定义删除器,用delete[])MySmartPtr<int, void(*)(int*)> p2(new int[5]{1,2,3,4,5},[](int* arr) { delete[] arr; } // 数组删除器);// 3. 管理Socket(自定义删除器,用close)int sock_fd = socket(AF_INET, SOCK_STREAM, 0);MySmartPtr<int, void(*)(int*)> p3(new int(sock_fd),[](int* fd) { close(*fd); delete fd; } // Socket删除器);// 函数结束,自动调用对应删除器释放资源,无泄漏
}

科普基础:

new int[10]是分配 10 个 int 大小的内存但未初始化(值是随机垃圾值),

new int[5]{1,2,3,4,5}是分配 5 个 int 内存并显式初始化(依次赋值 1/2/3/4/5)。

new int(10)在堆内存上申请一个int类型的存储空间,并将其初始化为数值 10,最终返回指向这个单个整型变量的指针。

  • new int(10) 是指针初始化表达式,不是函数;() 在这里是初始化值,不是函数调用。
  • 带变量名的标准写法int* a = new int(10);

  • 去掉括号的等效写法(无括号初始化)int* a = new int;  *a = 10;  delete a

  • new 后面必须跟数据类型(int/char 等),不能直接跟变量名,这是 C++ 固定语法。

差异对比:

无模版,硬编码写死int VS 一个弄成模版类。

析构硬编码delete ptr;,无删除器概念 VS 删除器也做成可配置的模版参数(lambda作为函数指针传入)

具体解释语句:

第一个:

模板是和类一起匹配出现的template <typename T, typename Deleter = std::default_delete<T>> class MySmartPtr {,叫【模板类声明】,其中:

  • template <typename T, ...>声明这是一个模板类,T是用户使用时指定的类型参数(比如MySmartPtr<int>就把T替换成int);

  • typename T第一个模板参数
  • typename Deleter = std::default_delete<T>第二个模板参数Deleter(代表 “删除器类型”);

  • Deleter是自定义占位符(代表删除器类型);= std::default_delete<T>是默认参数语法(和函数默认参数逻辑一致),用户不传入这个参数时,默认用std::default_delete<T>,核心逻辑是接收指针参数ptr,固定执行delete ptr(因为delete ptr 是 C++ 内置语法,作用是释放ptr指向的动态内存并销毁对象,std::default_delete<T>将其作为标准实现),del(ptr)就是调用这个默认删除器,把我们类里自己定义的ptr指针传给它,触发delete ptr执行内存释放。

整体作用:让这个智能指针能适配任意类型+任意释放逻辑,不写死类型和释放方式。

第二个:

explicit MySmartPtr(T* p = nullptr, Deleter d = Deleter{}) : ptr(p), del(d) {},叫【构造函数的模板适配】,之前构造都是int,换成模版就懵了(和之前的多重指针一样,《编程指北的 C++》篇搜“妈 100 个”,并不是真会、并不是真懂,最后操精通了),成员列表初始化始终卡我,这回狠狠操懂了,Deleter 不是 C++ 内置关键字,它和你写的 ptrp1 一样,只是一个自定义的名字(标识符),这里为了见名知意(代表删除器类型)。结合默认值看实际效果,当你写 MySmartPtr<int> p1(new int(10)); 时:

  1. 没有指定第二个模板参数,自动使用默认值 std::default_delete<int>

  2. 模板里的 Deleter 被替换为 std::default_delete<int>

  3. 类内的 Deleter del; 就等价于 std::default_delete<int> del;

那传递参数是长啥样子?

手动指定第二个模板参数 + 传入对应的可调用对象(lambda、函数指针等),覆盖默认的 std::default_delete<T>

示例 1:传递数组删除器(适配 new[],用 delete[] 释放)(这就已经开始引出lambda了)

// 1. 指定模板参数:T=int,Deleter=函数指针类型 void(*)(int*)
// 2. 第二个实参传入lambda表达式,就是传递的删除器
MySmartPtr<int, void(*)(int*)> p2(new int[5],[](int* arr) { delete[] arr; }
);
此时 del(ptr) 执行的是你写的 delete[] arr 逻辑。

示例 2:传递 Socket 资源删除器

int sock_fd = socket(AF_INET, SOCK_STREAM, 0);
MySmartPtr<int, void(*)(int*)> p3(new int(sock_fd),[](int* fd) { close(*fd); delete fd; }
);

此时 del(ptr) 执行的是关闭套接字 + 释放内存的逻辑

衍生:

Deleter d = Deleter{}等价于int d = int{}Deleter{} 是值初始化 Deleter 类型的临时对象,作为参数默认值。

int{} 是 C++11 引入的统一初始化写法,给int类型做值初始化,完全等价于0,和你熟悉的写法直接对应

// 你熟悉的基础写法
int a = 0;
// 用int{}替换0,效果一模一样,变量a的值也是0
int a = int{};

放在函数参数里的等效演示

// 你熟悉的常规写法:参数默认值为0
void func(int num = 0);
// 用int{}写默认值,和上面完全等效,主流编译器支持,效果一致
void func(int num = int{});

int{} 这种花括号初始化对 int 这种基础类型没有实用意义,和写 0 效果完全一样,这是最核心的点。它的设计意义是给类、自定义类型用的:自定义类型没有固定的默认值 0,类名{} 能统一创建出该类型的默认构造对象,C++ 标准把这套语法推广到了 int 等基础类型,才出现了int{}这种写法,单纯为了语法格式统一。对应到你之前的Deleter d = Deleter{}Deleter是自定义的删除器类型,必须用Deleter{}创建默认实例,没法写Deleter d = 0,这才是这套语法存在的实际意义,用在 int 上只是规则延伸,没有额外作用。

衍生: 

成员列表初始化本质就挺 JB 绕的,狠狠操明白了发现挺简单,先看个int

查看代码
class Example {
// 类的成员变量
private:int value;      int data;       // 构造函数部分
public:// 标准构造函数(带默认参数 + 成员初始化列表)Example(int p = 0, int d = 0) : value(p), data(d) {// 函数体:这里可以写额外逻辑,空着也合法}
};
  • Example(int p = 0, int d = 0)这是构造函数的收货口,pd是临时接数据的小篮子(叫构造函数对外接收的参数,是临时变量,只在构造函数里用,=0 是它的默认兜底值,称之为形参、入参、函数参数),=0是兜底方案,你要是忘了传数据,篮子里自动装 0,不会空着。

  • 冒号就是一个切换开关,标记着:接下来不接新数据了,开始把接收到的数据,塞进类自己的储物箱里。

  • value(p), data(d)这是核心的搬运动作,直接把临时篮子p里的数倒进专属箱子value,篮子d里的数倒进箱子data,一步到位完成赋值,比在大括号里写赋值代码更直接高效。

value:类的成员变量(类里私有的那个变量,是最终要存数据的容器)

p:就是上面说的入参,把原料倒进容器里

  • 最后的大括号 {}这是流程收尾区,要是赋值完还想加额外操作(比如打印日志、校验数据)就写在里面,纯赋值的话空着就行,完全不影响核心功能。

你的T* p = nullptr就是这个示例里int p = 0,只是接的是指针类型,默认值为空;

你的Deleter d = Deleter{}就是这个示例里int d = 0,用{}生成默认值而已;

操你血妈的这些法规定的好他妈绕啊~~~~(>_<)~~~~

第三个,lambda

前言:

以为得学 2、3 天,之前啃垃圾菜鸟教程的时候,他妈的追问lambda 3 天没正确的回答,问豆包反复矛盾,甚至牵扯很多过时不用的,一头雾水搁置了,这次学了 1h 就懂了,真挺简单的,主要是 7 个烧饼理论,再加上有过两次的痛苦至极浪费无数时间的超级血案的教训(RAII 那篇遇到的,豆包总会基于我会,然后为了不遗漏,而永远都是先给我极端小众的知识点,导致在我完全不会想学主流基础的情况下,先学了一堆自己感觉近乎完全矛盾的边边角角的东西),然后会提问了,但也是巧合,真架不住死妈玩意豆包反反复复误人子弟胡编乱造

提问提示词:

禁止深冷偏,禁止任何极端小众,大厂LinuxC++服务端开发基础岗必须会的。回答风格禁止应试背东西!把面试要考的你按照小白 0 基础教程一样给我!灵活点!禁止出现死板的背诵或者应试的东西!适当给代码让我实践!毕竟你这狗东西之前总误人子弟!

提问提示词:

从此如果没错禁止任何更改和道歉!你如果真错必须道歉!你只有坚定客观事实才能更帮助我啊!!我每次说的提问都是自己的思考不一定是对的!!禁止任何多余解释!我问你是啥你回答啥!没问的一律禁止回答!

提问提示词:

不是哥们!!我为啥随便挑出来一个你都能说错呢??这都是你给的啊!!你到底能不能给一个100%对的啊啊?? 操你.妈你回回答了不下100次了!!我随便说一句话你都能说错!!!你他妈真气死我了啊!!你到底咋回事啊!!我真的痛苦至极啊!!你能不能参考个铁定对的国外技术官网啊!!!!你说你哥们一下午了!!反反复复在纠正自己结果也依旧全是错的!求你了别再我挑出一句话问“你是否错了?错了吧??”你就说“确实错了”!!你个狗逼我咋学习??!!禁止听我口气,从此必须严谨参考客观事实!!

狗逼豆包他妈的设置里的记忆功能就是摆设,一次性给他多个要求过多也完全记不住,AI 生视频也是,要素过多不行。

MySmartPtr<int, void(*)(int*)> p2(new int[5]{1,2,3,4,5},[](int* arr) { delete[] arr; } // 数组删除器
);
  • 类型匹配:模板参数void(*)(int*)直接定义了类中Deleter del;的类型为接收int*类型参数、无返回值的函数指针,覆盖了默认的std::default_delete<T>类型;

  • 赋值逻辑:构造函数的第二个参数[](int* arr) { delete[] arr; }匿名函数完美匹配void(*)(int*)的规则:入参int*、无返回值,因此可以被赋值给del变量,存储在类内。本质是符合该函数指针签名的函数,通过初始化列表del(d),直接赋值给成员变量del,此时del存储的是这个数组释放函数的地址;

  • 执行调用:析构函数 / 移动赋值中执行del(ptr),就是调用del存储的数组释放函数,把类内的ptr(指向int数组)作为参数传入,执行delete[] arr,完成数组内存释放,区别于默认删除器的delete ptr

和下面的普通函数用法完全一模一样,只是换成了匿名写法:
// 定义一个匹配void(*)(int*)类型的普通函数
void freeIntArray(int* arr) {delete[] arr;
}// 直接用普通函数初始化智能指针,和你的Lambda效果完全相同
MySmartPtr<int, void(*)(int*)> p2(new int[5], freeIntArray);

del存的是函数地址,del(ptr

思考疑问:

MySmartPtr<int, void(*)(int*)> p2(new int[5]{1,2,3,4,5},[](int* arr) { delete[] arr; } // 数组删除器
);咋跟class MySmartPtr {
private:T* ptr;Deleter del; // 存储自定义删除器(默认是std::default_delete,即delete)public:// 构造:接收指针+删除器(删除器默认)explicit MySmartPtr(T* p = nullptr, Deleter d = Deleter{}) : ptr(p), del(d) {}匹配衔接的呐?

第一步:把模板当成「可填空的表格」

你写的类 MySmartPtr<T, Deleter> 是一个空白模板表格,TDeleter 就是两个空白格子,你在创建p2的时候,往格子里填了具体内容:

// 填格子:T=int ,Deleter=void(*)(int*)
MySmartPtr<int, void(*)(int*)> p2(...)

填完之后,编译器会把模板里所有的Deleter,一字不差替换成你填的内容,这是机械替换,没有任何逻辑。

第二步:机械替换后的代码长这样(核心!)

替换前的模板代码:
private:T* ptr;Deleter del;
public:explicit MySmartPtr(T* p = nullptr, Deleter d = Deleter{}) : ptr(p), del(d) {}

替换后(只看和Deleter相关的部分):

private:int* ptr;// Deleter 被替换成 void(*)(int*),这是函数指针的写法void(*del)(int*);
public:// 构造函数参数也同步替换explicit MySmartPtr(int* p = nullptr, void(*d)(int*) ) : ptr(p), del(d) {}

void(*del)(int*) 翻译成人话:定义一个叫del的变量,它只能存「接收 int * 参数、没有返回值」的函数地址。

第三步:传参就是给这个变量赋值

你传的 [](int* arr) { delete[] arr; },本质是一个符合要求的函数,把它传给构造函数,就等于执行了:del = 你传的函数地址;

第四步:调用就是执行存好的函数

析构函数里的 del(ptr),等价于拿着变量里存的函数,把 ptr 传进去运行,最终就执行了函数里的delete[] arr

再降维:用普通变量对标理解

del当成一个普通的整数变量,逻辑完全一致,只是存的东西从数字变成了函数地址:

查看代码
// 模板写法(存数字)
template <typename NumType>//完全可以写成 T
class Test{NumType val; // 占位符类型Test(NumType v) : val(v) {}
};
// 填空使用
Test<int> t(10); 
// 替换后:int val; 构造函数赋值 val=10// 你的代码(存函数地址)
template <typename Deleter>
class MySmartPtr{Deleter del; // 占位符类型MySmartPtr(Deleter d) : del(d) {}
};
// 填空使用
MySmartPtr<void(*)(int*)> p2(函数地址); 
// 替换后:void(*del)(int*); 构造函数赋值 del=函数地址

唯一区别:普通变量存数字,del变量存函数的地址,调用方式del(ptr)就是运行这个函数。

Q:第三步[](int* arr) { delete[] arr;不懂

A:把它还原成你能看懂的【普通函数】

// 这是和Lambda功能、参数、返回值完全一样的普通函数
void freeIntArray(int* arr) {// arr 就是 int* 类型的指针,和你认识的 int* a 是同一个东西delete[] arr;
}

对应关系:

Lambda 写法 普通函数写法 含义说明
[] 函数名freeIntArray 无捕获 = 不需要外层变量,省略函数名
(int* arr) (int* arr) 入参是int 类型指针,和int* a语法一致
{ delete[] arr; } { delete[] arr; } 函数体:用delete[]释放数组指针

单独解释int* arr(你最熟悉的部分)

int* arr

  • int* 定义了这是一个指向 int 类型数据的指针;

  • arr 是指针变量名,存储的是一块 int 数组内存的首地址;

  • 它就是用来接收你new int[5]创建出来的数组指针的。

解释delete[] arr(关键区分)

  • delete:用来释放单个对象的堆内存(比如new int(10));

  • delete[]:专门用来释放数组类型的堆内存(比如new int[5]);

  • 这里写delete[] arr,就是告诉编译器:释放arr指针指向的整块 int 数组内存。

解释最前面的[]

这个方括号是 Lambda 的捕获列表,这里留空代表:这个匿名函数不使用函数外面的任何变量,只使用自己参数里的arr,仅此而已。

和你的智能指针代码绑定(回到原场景)

  1. 你传入的[](int* arr) { delete[] arr; },等价于传入了freeIntArray这个函数;

  2. 它匹配模板里的void(*)(int*)函数指针类型(入参 int*、无返回值);

  3. 这个函数被赋值给类里的del变量,析构时执行del(ptr),就是把ptr传给arr,运行delete[] arr

lambda

第一步:先忘掉 Lambda,写一个你完全熟悉的普通函数,功能:接收一个int*指针,释放数组内存

#include <iostream>
// 普通有名字的函数
void freeArray(int* arr) {// 释放new int[] 申请的数组内存delete[] arr;
}int main() {// 堆上申请int数组,变量名pint* p = new int[3]{1,2,3};// 调用普通函数释放内存freeArray(p);std::cout << "普通函数释放内存成功" << std::endl;return 0;
}
运行结果:
普通函数释放内存成功

核心记住:这个函数的签名:void(int*),无返回值,参数是int*,这是后面匹配的关键。

第二步:搞 Lambda(替换上面的普通函数,无任何黑魔法),Lambda 就是没有名字的函数,写法固定,我们直接替换freeArray,代码可直接运行:

#include <iostream>int main() {int* p = new int[3]{1,2,3};// 这就是你的目标Lambda:匿名函数,功能和freeArray完全一样// [捕获列表] (参数) {函数体}[](int* arr) {delete[] arr;}; // 注意:现在只定义了函数,还没调用!std::cout << "Lambda定义完成,未调用" << std::endl;return 0;
}
运行结果:
Lambda定义完成,未调用

第三步:调用 Lambda(两种实操写法,复制运行)

写法 1:定义后直接调用(最直观)

#include <iostream>
int main() {int* p = new int[3]{1,2,3};// 定义 + 直接调用:末尾加(p),把指针传进去[](int* arr) {delete[] arr;}(p); std::cout << "Lambda定义并直接调用成功" << std::endl;return 0;
}

写法 2:把 Lambda 存到变量里(对应你智能指针的赋值逻辑)

这是最贴合你场景的写法!智能指针就是把 Lambda 存在成员变量里,后面再调用。

#include <iostream>
int main() {int* p = new int[3]{1,2,3};// 重点:用函数指针变量存储Lambda// 函数指针类型:void(*变量名)(int*),和我们的函数签名匹配void(*del_func)(int*) = [](int* arr) {delete[] arr;};// 通过变量调用Lambda,和调用普通函数一模一样del_func(p);std::cout << "Lambda存入变量后调用成功" << std::endl;return 0;
}
两个代码运行结果都是
Lambda定义并直接调用成功
Lambda存入变量后调用成功
第四步:绑定到你的核心疑问:模板 + 函数指针 + Lambda

结合你最开始看不懂的模板场景,写一个极简智能指针类,完整跑通流程,复制运行:

查看代码
#include <iostream>// 简化版智能指针模板,就是你源码的核心结构
template <typename T, typename Deleter>
class MySmartPtr {
private:T* ptr;Deleter del; // 模板参数替换后:void(*del)(int*)
public:// 构造函数:接收指针 + 删除器(Lambda/普通函数都可以)MySmartPtr(T* p, Deleter d) : ptr(p), del(d) {}// 析构函数:自动调用删除器释放内存~MySmartPtr() {del(ptr); // 调用存好的Lambda/函数std::cout << "析构函数调用删除器,内存已释放" << std::endl;}
};// 普通函数,备用对照
void freeArray(int* arr) {delete[] arr;
}int main() {// 用法1:传入Lambda(你最疑惑的写法)MySmartPtr<int, void(*)(int*)> ptr1(new int[5], [](int* arr) { delete[] arr; });// 用法2:传入普通函数(和Lambda效果完全一致)// MySmartPtr<int, void(*)(int*)> ptr2(new int[5], freeArray);
}
运行结果
析构函数调用删除器,内存已释放

插一句:

傻逼豆包无意间说了无关的,既然看到就说下

拷贝初始化 ≠ 调用拷贝构造函数:

class A {
public:// 转换构造:无explicit单参,允许隐式转换A(int) {}// 拷贝构造A(const A&) {}
};

类 对象 = 右边值 这种写法,语法上统一都叫拷贝初始化,不分右边是对象还是数字,有两个写法:

  • A a = b拷贝初始化,右边是同类对象 → 直接调用 拷贝构造函数

  • A a = 10;照样是拷贝初始化(只是一种语法写法,不代表一定会调用拷贝构造函数),右边是 int → 触发隐式转换,调用 A (int) 转换构造,也叫带参构造里的单参构造(没加explicit会隐式,也称作转换构造,这里又有两写法:A a(10)直接调用带参构造,和A a = 10触发隐式转换,用单参构造生成临时对象再初始化,只不过一般编译器会优化抹平这个临时,即:语法规定本来要走两步:10 → 造出临时对象 → 再拷贝给 a。编译器直接偷工减料:跳过临时、跳过拷贝,直接把 10 塞给 a 的 A(int) 构造函数)

默认构造(啥构造都不写):class A {}; A a; // 编译器自动给你造了一份默认构造,相当于手写A(){} ,或者有参但参数是默认值A(int x = 0){} 也是默认构造。

第五步:必踩坑提醒(实操避错)

  1. deletedelete[]不能混用

    • new int → 用delete

    • new int[5] → 用delete[]

  2. 只有空捕获[] 的 Lambda,才能赋值给函数指针;

  3. 模板里的Deleter就是机械替换,把类型填进去即可,没有隐藏逻辑。

Q:针对lambda这个知识点狠狠艹

A:Lambda 是匿名函数(没有名字的函数),用于临时编写简短逻辑,语法固定格式:

[捕获列表] (参数列表) -> 返回值类型 { 函数体 };
简化版(大部分场景适用,编译器自动推导返回值,可省略-> 返回值类型):
[捕获列表] (参数列表) { 函数体 };
可直接运行的最简示例:
#include <iostream>
using namespace std;int main() {// 定义无参数、无捕获、无返回值的Lambda,定义后直接调用 ()[]() { cout << "Hello Lambda!" << endl; }();
}
//运行结果:Hello Lambda!

末尾加()就是调用 Lambda,和调用普通函数一致。

四大重要组成部分:

写法 含义 代码示例
[] 空捕获,不使用任何外层变量 [](){ cout << "无外部变量"; }
[=] 值捕获,复制所有外层变量到函数内 [=](){ cout << a; }
[&] 引用捕获,直接使用外层变量的引用 [&](){ a = 100; }
[a, &b] 混合捕获:a 值捕获,b 引用捕获 [a, &b]{ b += a; }

捕获列表 [],写在最开头的方括号,获取外层作用域的变量,看代码:

查看代码
#include <iostream>
using namespace std;int main() {int a = 10;int b = 20;// 1. 值捕获:复制变量,修改不影响原变量[=]() {// a = 50; 错误!值捕获的变量默认只读cout << "值捕获 a = " << a << endl;}();// 2. 引用捕获:修改会直接改变原变量[&]() {b = 99;cout << "引用捕获修改后 b = " << b << endl;}();// 3. 混合捕获[a, &b]() {b += a;}();cout << "混合计算后 b = " << b << endl;
}

值捕获是拷贝副本,原变量不受影响;引用捕获直接操作原变量。

参数列表 ()和普通函数的参数完全一致,支持传值、传引用,可省略(无参数时):

#include <iostream>
using namespace std;int main() {// 带参数的Lambda:接收两个int,求和auto add = [](int x, int y) { return x + y; };// 调用Lambda,传入参数 3 和 5int res = add(3, 5);cout << "3 + 5 = " << res << endl;
}
//运行结果:3 + 5 = 8

auto接收 Lambda 是最常用写法,简单无脑。

返回值类型,编译器会自动推导返回值,绝大多数场景不用写;只有逻辑复杂、返回多类型时才手动指定。手动指定返回值:

#include <iostream>
using namespace std;int main() {// -> int 手动声明返回值为整型auto mul = [](int x, int y) -> int { return x * y; };cout << "4*6 = " << mul(4,6) << endl;return 0;
}
函数体 {},和普通函数函数体一致,写业务逻辑、变量操作、输出等均可。

Lambda 可变修饰 mutable,默认情况下,值捕获的变量是只读的,不能修改,加上mutable关键字即可修改副本,仅修改值捕获的副本,不会改变外层原变量:

#include <iostream>
using namespace std;int main() {int num = 10;// 加mutable,允许修改值捕获的变量副本[num]() mutable {num += 5; // 合法,修改的是副本,原变量不变cout << "Lambda内num副本:" << num << endl;}();// 原变量不受影响cout << "外部原num:" << num << endl;
}
/*
运行结果:
Lambda内num副本:15
外部原num:10
*/
Lambda 赋值给变量:用auto接收 Lambda,后续可以重复调用,是工作 & 面试最常用写法:
#include <iostream>
using namespace std;int main() {// 用变量func存储Lambdaauto func = [](int n) { return n * n; };// 多次调用cout << "2的平方:" << func(2) << endl;cout << "5的平方:" << func(5) << endl;return 0;
}

Q:箭头是啥?

A:这是 Lambda 显式指定返回值类型 的语法,格式:

[捕获](参数) -> 返回值类型 { 函数体 }

作用:手动告诉编译器,这个 Lambda 的返回值是什么类型,和普通函数写返回值类型是一个作用。

两段代码对比解释:

代码 1:手动写 -> int 指定返回值

auto mul = [](int x, int y) -> int { return x * y; };
  • -> int:明确规定这个 Lambda必须返回 int 类型

  • 适合复杂逻辑、多分支返回不同类型时,强制约束返回值,避免编译器推导错误

代码 2:不写箭头,编译器自动推导返回值

auto add = [](int x, int y) { return x + y; };
  • 没有->,编译器会根据return x + y自动判断返回值是int

  • 简单逻辑(单分支、类型明确)都可以省略,这是日常最常用的写法

结论:功能完全等价,什么时候必须写箭头?当 Lambda 内部有多个 return 语句,返回不同类型的值时,编译器无法自动推导,必须手动用->指定返回类型
#include <iostream>
using namespace std;int main() {// 写法1:显式声明返回值 intauto mul = [](int x, int y) -> int { return x * y; };cout << "4*6 = " << mul(4,6) << endl;// 写法2:自动推导返回值auto add = [](int x, int y) { return x + y; };int res = add(3, 5);cout << "3 + 5 = " << res << endl;
}
/*
运行结果:
4*6 = 24
3 + 5 = 8
*/
普通函数的写法和 Lambda 箭头语法是对应关系,帮你对标理解:
// 普通函数:int 写在最前面,指定返回值
int add_func(int x, int y) {return x + y;
}
// Lambda:-> int 写在中间,作用完全一样
auto add_lambda = [](int x, int y) -> int { return x + y; };

代码auto mul = [](int x, int y) -> int { return x * y; };return x*y 明确返回整型,编译器100% 能自动推导出返回值是 int,这里写 -> int 没有任何实际作用,纯冗余。

那这个语法存在的意义是什么?-> 返回值类型 是为了解决编译器无法自动推导的场景,只有在这些情况才必须写,给你看两个必须使用的实操案例:

场景 1:多分支返回不同类型(编译报错,必须手动指定),如果 Lambda 内有多个return,返回类型不一致,编译器会报错,必须用箭头强制统一返回类型:

#include <iostream>
using namespace std;int main() {// 错误写法:无箭头,一个return int,一个return double,编译器无法推导// auto func = [](int a) {//     if(a > 0) return 1;    // int类型//     else return 1.5;       // double类型// };// 正确写法:用-> double强制指定统一返回值类型auto func = [](int a) -> double {if(a > 0) return 1;else return 1.5;};cout << func(-1) << endl;
}
  • a>0返回1,否则返回1.5,程序运行时只会走其中一个分支;

  • 但 C++ 编译器不会做运行时逻辑判断,它只在编译阶段检查所有return语句的类型,只要多个分支返回类型不一致,直接判定语法错误,根本不会管分支会不会同时执行。

报错的根本原因,编译器编译 Lambda 时,会扫描函数体内所有return

  • return 1 → 类型是int

  • return 1.5 → 类型是double

    两个返回类型不匹配,编译器无法确定最终返回值类型,直接抛出编译错误,连运行的机会都没有。

如果大于 0,输出是 1 不是 1.0,cout 默认会省略小数部分为 0 的浮点数的小数点和后缀 0,把 1.0 显示成 1

场景 2:代码风格统一 / 团队规范:部分项目规范要求所有 Lambda 显式声明返回值,提升代码可读性,这是工程化约定,而非语法强制要求。

至此一路从智能指针,七进七出反复抽插到模板、自定义删除器、函数指针、成员列表初始化、构造具体咋回事、lambda。

那此文搜“思考疑问:”就懂咋匹配的了,

第一个参数传递:new int[5]{1,2,3,4,5} 是在堆上创建 int 数组,返回数组首地址,这个地址赋值给构造函数的形参 p,最终初始化成员变量 ptr。即:ptr = new int[5] 返回的指针

第二个参数传递:[](int* arr) { delete[] arr; } 这个 Lambda,会被转换为函数指针 void(*)(int*),赋值给构造函数形参 d,最终初始化成员变量 del。即:del = 你传入的Lambda

del(abc) 到底做了什么?析构函数里执行 del(ptr);,对应你的代码逻辑:

  1. ptr 是托管的数组指针(指向new int[5]的地址),作为实参传给删除器;

  2. 删除器的形参 int* arr 接收这个指针,等价于 arr = ptr

  3. 执行 delete[] arr;,本质就是delete[] ptr;,释放数组内存。

直白对应关系

// 调用方
del(ptr);
// 等价于执行你的Lambda
[](int* arr) { delete[] arr; }(ptr);
// 最终等价于
delete[] ptr;

结论:del(abc) 就是把 abc(托管指针)传给删除器,用 delete[] 释放它,完全对应数组的释放规则

Q:我一直研究模版类,突然发现main里的用法还不太懂

A:

先科普点东西吧!!

关于无参构造:

  • 可以是手动显示定义的无参。

  • 也可以是默认构造是自动生的无参。

细节:代码里的构造函数是公有的,就可以间接给私有的成员变量赋值。

C++ 中类定义结束后的右花括号必须加分号。

nullptr 是 C++11 引入的空指针常量,专门用来表示空指针,比传统的 NULL 类型更安全、语义更清晰。

然后关于【写了构造就不会默认生无参构造】:

  • 构造如果是Example(int p = 0, int d = 0) : value(p), data(d) {,依旧可以Example obj1;(需要匹配无参)、Example obj2(10);Example obj3(10, 20);,因为有默认参数,所以Example obj1;(需要匹配无参)可以直接匹配当作无参构造来传递,没问题。结果都是 0。

  • 而如果构造里没有默认赋值,那就必须传递值,如果你搞成了Example obj1;就错了。

  • 如果想禁用可以用 C++ 11 标准语法

    class Example {
    private:int value;      int data;       
    public:// 显式删除 默认构造函数 → Example obj1; 直接编译报错Example() = delete;// 保留带参构造函数,移除所有默认参数!Example(int p, int d) : value(p), data(d) {}
    };
    int main(){// Example obj1;    // 编译报错:尝试使用已删除的函数,符合你的需求// Example obj2(10); // 编译报错:构造函数需要两个参数Example obj3(10, 20); // 仅这一种写法合法
    }
    或者将默认构造函数声明为private(旧版兼容写法,不推荐优先用),在 C++11 之前常用,将无参构造函数私有化,外部无法调用,现在仅用于兼容极老的代码,新项目一律用=delete
    class Example {
    private:int value;int data;// 私有化无参构造,外部无法调用Example();
    public:Example(int p, int d) : value(p), data(d) {}
    };

发现指针还是不熟悉,稍微回顾下:

之前刷算法题的迷宫搜索,三维数组我就默认成一个立体的长宽高了,并没太深纠结,如今涉及到数组各种指针真的头疼,就钻牛角尖了,说什么 3、4 维空间?说什么我们存在的就是 3 维,数组为啥有好几维?然后说什么苍蝇飞不出啥来着?因为它生活在几位空间?还是猪啥的?什么不会拐弯?这些维度究竟是啥?

原来,

一维是单横单竖这种,对应数组就是一组数组。

二维就是平面,对应数组就是几行几列。

三维是空间长宽高,对应数组也一样。

四维是已经无法想象了,属于时空维度,对应数组无法想象只能类似int arr[2][2][2][2];硬写,理论就是每多一维,就是在上一维的基础上再嵌套一层数组,完全无法想象。

更高维更完全无法想象出来。

人类的物理空间和直观可视化能力,最多支撑到 3 维,更高维度无法在现实世界画出实体结构

计算机里高维是一长串连续内存地址。

人活在三维物理空间,平时说的 “四维时空” 是物理为了描述运动(把时间和三维空间绑定建模)的抽象概念,不是真的有第四个空间维度,时间只是个记录事件先后的维度,和长宽高的空间维度本质不一样。

高维应用有

  • 科学计算 / 物理模拟:描述时空 + 多物理量:比如[时间][空间x][空间y][空间z][物理属性(温度/密度/压强)],这就是标准的 5 维数据,用于流体力学、气象模拟。

  • 人工智能 / 深度学习(最常用):框架(TensorFlow/PyTorch)中的张量(Tensor) 就是广义的多维数组,图像处理、NLP 任务中经常用到 4~5 维张量:例:[批次][通道数][高度][宽度][帧序列] 用于视频特征提取,本质就是 5 维数据结构。

  • 把时间当作第 4 条几何轴(w 轴),四维坐标 就能描述一个三维物体在某个时刻的空间位置。

所以纯纯他妈傻逼扯犊子玩意,全是假维度伪概念!那老子来一个 4 维:【姓名】【性别】【爱好】【身高】!!

原本典故根本不是猪 / 苍蝇,是蚂蚁(最经典),后来被传歪成各种动物,核心是拿「假想二维生物」做比喻,和现实生物无关。

把假想的二维蚂蚁放在一张纸上,只会在纸上走,比喻低维认知的生物,无法理解高维空间的存在和操作方式。但蚂蚁是三维生物,只是其活动范围主要局限在二维平面,身体的结构和存在形式依旧是三维的。

至于你听到的「猪不会拐弯」,纯粹是典故被传烂后的错误变种,和维度类比无关,就是人瞎编的;现实里所有地球生物,哪怕是只会爬的虫子,都是生存在三维空间里的,哪怕它不会飞、不会拐弯,也只是生理能力问题,不是感知不到第三维。

说数组: 
  • 数组指针,指向整个数组的指针,类型int(*)[5]

    // 定义长度为5的int数组
    int arr[5] = {1,2,3,4,5};
    // 定义数组指针,指向【长度为5的int数组】
    int (*p)[5] = &arr;
  • 数据元素指针,类型int*数组返回值也是这个,即首元素地址(这句话很有用!)

    new int[5] 返回 int*(指向数组首元素)
    int* elem_ptr = new int[5]{1,2,3,4,5};

这两个指针存储的内存地址数值相同,但类型完全不同

n 维数组,会自动退化为指向其「n-1 维子数组」的指针,说一个传递 n-1 维数组的起始地址的例子,int arr[2][3] 是2 行 3 列的二维数组,在内存中是连续存储的,逻辑结构可以拆分为:两个一维子数组:arr[0](元素{1,2,3})、arr[1](元素{4,5,6}),

注意:

int arr[2][3] = {{1,2,3},{4,5,6}} 中,数值 1 是一维子数组 arr[0] 的首元素,不是二维数组 arr 的首元素,二者层级完全不同。

二维数组的本质是数组的数组:arr 是一个包含 2 个元素的数组,这 2 个元素均为长度 3 的 int 型一维数组,分别是 arr[0]arr[1]

数组名在表达式中会自动隐式转换为指向自身首元素的指针,数组的首元素,是数组下标为 0 的元素,因此二维数组 arr 的首元素固定为 arr[0](一维数组),指向一维数组(长度3)的指针,类型正是 int (*p)[3]

语法糖:

p[i][j],等价于指针运算:

  1. p+i:指向第i个一维子数组的地址(n-1 维数组的地址偏移)

  2. *(p+i):解引用得到第i个一维子数组本身

  3. (*(p+i))[j]:访问子数组的第j个元素

int (*p)[3]里的*是指针声明符,作用是声明p为一个指针变量,而非普通变量 / 数组。

  • 先看*pp是指针,*p代表指针指向的对象;

  • 再看[3]:说明p指向的是包含 3 个int元素的一维数组;

最终p的类型:指向长度为 3 的 int 型一维数组的指针(数组指针)。

对比几个其他的:

  • int (*p)[3]*声明指针 → 数组指针(一个指针,指向数组)

  • int p[3]:无* → 普通数组变量(存储 3 个 int 元素)

  • int *p[3]:无括号,[]优先级高于*,优先解析 p[3] → 确定p是长度为 3 的数组;解析左侧 int * → 确定数组中每个元素的类型是int*(指向 int 的指针);* 修饰的是数组元素的类型,最终定义:存储 3 个int*指针的数组。

练手:

  • 形参int (*p)[3]必须写明列数 3,因为指针需要知道每个 n-1 维子数组的长度,才能正确计算指针偏移;

  • 通用规则延伸:三维数组int arr[2][3][4]传参,会退化为int (*p)[3][4],即指向二维子数组的指针,依旧遵循n维→n-1维的地址传递规则。

    int* arr = new int[5]{1,2,3,4,5};
    *arr = 10;       // 修改首元素
    arr[2] = 30;     // 下标访问元素
    for(int i=0;i<5;i++) cout << arr[i]; // 遍历数组
    delete[] arr;    // 配合指针释放内存

数组指针作用(有啥用)

1、传递二维数组(最实用!解决函数传参问题)

普通指针传二维数组会丢失数组列数信息,数组指针可以完整保留二维数组的维度,在函数里安全访问二维数组:

#include <iostream>
using namespace std;
// 用数组指针接收二维数组,明确列数为3
void printArr(int (*p)[3], int row) {for(int i=0; i<row; i++){for(int j=0; j<3; j++){// 直接通过指针访问二维数组元素cout << p[i][j] << " ";}cout << endl;}
}
int main() {// 2行3列的二维数组int arr[2][3] = {{1,2,3},{4,5,6}};// 传入数组指针,完整传递维度信息printArr(arr, 2);
}
  • p[i] 等价于 *(p+i),跳过i个完整的int[3]数组,定位到第i行数组的首地址;

  • 对行地址再取[j],等价于(*(p+i))[j],定位到该行第j个元素,最终就是p[i][j]

2、区分数组整体与单个元素,避免指针运算出错

普通int*指向数组首元素,加减是移动单个元素;数组指针加减是移动整个数组,维度更安全:

int arr[5] = {1,2,3,4,5};
int (*p)[5] = &arr; // 数组指针,指向整个数组
// 指针+1,跳过整个数组(20字节),而非单个int(4字节)
cout << "数组指针地址+1:" << (void*)p << " -> " << (void*)(p+1) << endl;

通透!

继续!

科普完这些说下模板的东西,main里传递的尖括号咋回事?

Example 是无模板的普通类,它的结构、构造函数参数都是编译前就固定死的:

  1. 类定义时没有 template<...> 声明,没有可变的模板参数;

  2. 构造函数 Example(int p, int d) 只接收运行时的构造参数;

  3. 使用时只需要圆括号 () 传构造参数,完全不需要尖括号。

这就是你之前一直用的写法,也是最基础的类使用方式,所以没接触过<>

模板类的使用规则:模板类(比如你提到的 MySmartPtr)和普通类本质区别:类的定义依赖「编译期确定的模板参数」,构造对象依赖「运行期传入的构造函数参数」,两者各司其职,必须严格区分:

符号 作用 传入内容 生效时机 必须匹配规则
< > 实例化模板,生成具体的类类型 模板参数(typename Ttypename Deleter 编译期 严格匹配 template <...> 声明的参数顺序、数量
( ) 调用构造函数,创建对象实例 构造函数参数(T* pDeleter d 运行期 严格匹配构造函数声明的参数顺序、数量
#include <iostream>
// 自定义删除器函数对象
struct MyDeleter {void operator()(int* p) const {std::cout << "自定义删除int指针\n";delete p;}
};int main() {// 步骤1:<> 传模板参数 → 匹配 template <typename T, typename Deleter>// T = int,Deleter = MyDeleter,编译期确定类型// 步骤2:() 传构造参数 → 匹配 MySmartPtr(T* p, Deleter d)// 传入int指针、删除器实例,运行期初始化对象MySmartPtr<int, MyDeleter> ptr(new int(100), MyDeleter());std::cout << *ptr << std::endl; // 输出:100
}

通过几个错误例子来更加透彻:

案例 1、漏写<>模板参数:编译直接报错,编译器不知道TDeleter是什么类型:

// 错误!模板类必须指定模板参数才能实例化
MySmartPtr ptr(new int(100), MyDeleter());
案例 2、<>参数顺序 / 数量不匹配:编译报错,违反模板参数匹配规则:
// 错误:模板参数顺序颠倒,和 template <typename T, typename Deleter> 不匹配
MySmartPtr<MyDeleter, int> ptr(new int(100), MyDeleter());

当你编写 MySmartPtr<MyDeleter, int> 时,编译器按从左到右的位置一一对应匹配:

  • 第 1 个实参 MyDeleter → 匹配模板第 1 个形参 T

  • 第 2 个实参 int → 匹配模板第 2 个形参 Deleter

// 类型替换后,MySmartPtr 的等效代码
class MySmartPtr<MyDeleter, int> {
private:// T = MyDeleter → T* = MyDeleter*MyDeleter* ptr;// Deleter = int → 删除器成员变成了int类型int deleter;
public:// 构造函数形参:T* = MyDeleter*explicit MySmartPtr(MyDeleter* p = nullptr, int d = int{}) : ptr(p), del(d) {}~MySmartPtr() {if (ptr) // 致命错误:int 类型变量不能像函数一样调用 del(ptr)del(ptr);ptr = nullptr;}
};

此时编译器开始逐行校验语法:

错误 1:构造函数参数类型不匹配,你传入的实参是 new int(10),类型是 int*;但构造函数要求的参数类型是 MyDeleter*int*MyDeleter* 是完全无关的指针类型,无法隐式转换,编译器直接判定类型不匹配。

错误 2:析构函数中的核心语法非法(最根本的报错)代码行:deleter(ptr);deleter 被替换为 int 类型,int 是基本数据类型,不是函数、不是函数对象、不是 lambda,不支持 () 调用运算符;编译器的语法规则明确:只有可调用类型才能使用 对象(参数) 的写法,基本整型不满足这个规则;同时,ptr 类型是 MyDeleter*,即便假设 deleter 是可调用对象,也和删除器预期接收的指针类型不匹配。

案例 3、()参数不匹配构造函数:编译报错,和普通类构造规则一致:

// 错误:构造函数需要2个参数,只传了1个
MySmartPtr<int, MyDeleter> ptr(new int(100));

再解释代码里正确的是咋个串联流程:

案例 1、管理普通 int*(默认删除器)

MySmartPtr<int> p1(new int(10));

模板实例化结果:

编译器根据传入的模板实参,生成专属的类类型:

  • 模板实参:只指定了int,第二个参数使用默认值std::default_delete<int>

  • 类型替换:

    • T = int

    • Deleter = std::default_delete<int>

  • 实例化后的核心成员(关键部分):

查看代码
class MySmartPtr<int, std::default_delete<int>> {
private:// 托管int类型的指针int* ptr;// 删除器成员:标准库默认删除器对象std::default_delete<int> del;public:// 构造函数:接收int*指针 + 默认删除器explicit MySmartPtr(int* p = nullptr, std::default_delete<int> d = std::default_delete<int>{}) : ptr(p), del(d) {}~MySmartPtr() {if (ptr) // 调用删除器释放资源del(ptr); ptr = nullptr;}int& operator*() { return *ptr; }int* operator->() { return ptr; }int* get() { return ptr; }
};
del(ptr) 执行逻辑与原因
  • 执行内容:delstd::default_delete<int>类型的对象,调用它等价于执行delete ptr;

  • 原因:std::default_delete<T>是标准库的函数对象,重载了operator(),内部实现为delete ptr;,专门用于释放单个堆对象的内存。

  • 业务行为:释放new int(10)在堆上分配的单个 int 变量内存,避免内存泄漏。

这里带默认删除器,对应构造函数必然支持单参数调用,所以必须要加explicit防止退化

关于退化这件事:(行业规范,所有单参必须加)

如果一个类的构造函数只有 1 个参数,编译器会自动把「参数类型」隐式转换成「类类型」。

隐式转有俩方法:一个是写=、一个是传递实参给形参匹配时。

双参不会隐士转,所以双参不需要explicit

前提:int* p = new int(10);

普通类写法:Person t(18); 不用尖括号。

模板写法:Test<int> t(p)

拷贝写法(等号两边遍都是Test<int>类型,不隐式):Test<int> t = Test<int>(p);,先拿右边Test<int>(p)造一个临时对象,再把这个临时对象拷贝 / 移动给左边t,和上面的模板写法完全一样。

隐式写法:Test<int> t = p; :等号左边是Test<int>对象,右边是int*指针,类型不匹配,走隐式。

加了explicitexplicit Test(T* p) : ptr(p) {},直接禁止:Test<int> t = p; 编译报错。只能老老实实写:Test<int> t(p);

查看代码
template<typename T>
class Test {
public:// 危险:无explicit 单参构造Test(T* p) : ptr(p) {}   //想加explicit就explicit Test(T* p) : ptr(p) {}~Test() { delete ptr; }
private:T* ptr;
};// 编译器偷偷做了:隐式转换 int* → Test<int>
int* p = new int(10);
Test<int> t = p; // 合法,编译器用 int* 调用单参构造,完成隐式转换// 更离谱:函数要Test,你直接传int*,也能过
void func(Test<int> t) {}
func(p); //int*自动转为Test<int>

这对普通类没事,但对智能指针是致命 BUG!比如:

// 简易版:只有单个指针参数
MySmartPtr(T* p) 

MySmartPtr<int> p = new int(10);,其中new int(10)int*类型,编译器看见 = 这种写法,发现类有单参构造,直接隐式调用构造函数。等价偷偷补全:MySmartPtr<int> p = MySmartPtr<int>(new int(10));

传递参数隐转也是一样:

查看代码
// 你只是单纯定义一个普通裸指针,根本不想被智能指针管理
int* raw = new int(666);// 函数参数要智能指针
void deal(Test<int> sp) {}// 坑就在这:你随手传了个裸指针
deal(raw);//想禁止这么写就加 explicit,但必须在Test那加,能发生隐式转换的源头,不是函数deal,是Test<T>这个类的构造函数。// 如果想传入,那正规写法,不触发隐式转换
deal(Test<int>(raw));
你以为只是传了个指针,实际发生了三件坏事:
  1. 编译器偷偷执行:Test<int>(raw) 隐式构造临时对象;

  2. 临时智能指针接管了你的裸指针;

  3. 函数执行完毕,临时对象销毁,自动 delete 释放了 raw 指向的内存。

后续你再用 raw 访问数据:直接野指针、内存重复释放、程序崩溃,完全无报错提示,极难排查。

Q:那你本意写deal(raw)是想干啥?

A: 真实开发中,代码里通常会有两个重载函数(名字一样,参数不同):
// 函数1:处理 裸指针
void deal(int* raw_ptr) {// 只是打印、读取数据,根本不释放内存cout << *raw_ptr;
}// 函数2:处理 智能指针
void deal(SmartPtr<int> sp) {// 业务逻辑
}

你的真实操作:我想调用【函数 1】,只是手滑 / 没看清,写成了 deal(raw),本意是传裸指针、读数据,完全没想碰内存释放。

编译器的坑操作:它发现你传的 raw(裸指针)匹配不上【函数 1】,就偷偷看【函数 2】——「哦!这个智能指针有单参构造,没加 explicit!我帮你转一下吧!」

直接隐式转换,偷偷把你的裸指针包成智能指针,函数结束直接删内存

 

模板规定:template <typename T, typename Deleter = std::default_delete<T>>

  1. T 无默认值 → 必须写 ,比如写<int>

  2. Deleter 有默认值 → 不用写

// 把默认的删除器也写上,完全多余
MySmartPtr<int, std::default_delete<int>>

MySmartPtr 本身不是完整的类名,它只是一个模板壳子,必须写 <int> 给模板填类型,才是完整的类:MySmartPtr<int> = 专门管理 int 类型指针的智能指针。

这是尖括号的事,叫模板参数。

括号是另一个独立的东西,叫函数参数,由于都初始化了,所以传递0、1、2个参数均可。

 

关于template <typename T, typename Deleter = std::default_delete<T>>用法:

构造如果是:MySmartPtr<int> sp(new int(666));,你这里只写了 int,没给第二个参数,Deleter 自动 = std::default_delete<int>

你 new 出来的那个 int* 指针,被智能指针存起来,等到智能指针生命周期结束,自动把存好的这个 new 出来的指针,丢给 std::default_delete<int>,它内部直接帮你做了 delete 这个指针

想自己定义:MySmartPtr<int, 自己写的删除器> sp(new int(666));直接把自带的 std::default_delete 挤掉,用你自己的规则删。

 

Q:template <typename T, typename Deleter = std::default_delete<T>>explicit MySmartPtr(T* p = nullptr, Deleter d = Deleter{}) : ptr(p), del(d) {}咋写两遍的第二个参数?

A:模板只管定义是什么类型不提具体值,构造函数给这个类型的参数,提供一个默认的具体对象({} = C++ 语法:创建一个该类型的空对象)。

typename Deleter = std::default_delete<T>>这就是给模板参数Deleter指定默认类型。

explicit MySmartPtr(T* p = nullptr, Deleter d = Deleter{})用上面模板定好的 Deleter 类型,造一个默认实例对象当参数默认值。

这里T*是因为智能指针本来就是用来托管堆上 new 出来的东西。 

std::default_delete<T> 就是 C++ 标准库自带的一个现成 “帮你调用 delete 释放内存” 的工具类,不用自己手写删除逻辑,智能指针默认就用它来删指针,内部帮你做 delete 指针

案例 2:管理int 数组(自定义函数指针删除器)

MySmartPtr<int, void(*)(int*)> p2(new int[5]{1,2,3,4,5},[](int* arr) { delete[] arr; } // 数组删除器
);

1、MySmartPtr<int, void(*)(int*)>:智能指针管理 int 类型对象,且强制指定删除器的类型是「指向参数为 int*、返回值为 void 的函数」的函数指针;

语法规定:(类型 (*)(形参)) 固定格式就是函数指针,括号包住*就是标识指向函数而非普通变量。

带变量名的完整函数指针:void (*funPtr)(int*);

  • void:返回值

  • (*funPtr):funPtr 是函数指针变量名

  • (int*):参数列表

模板里去掉变量名,只剩纯类型:void (*)(int*),把中间变量名 funPtr 删掉,剩下的就是纯类型,专门当模板参数用。

2、new int[5]{1,2,3,4,5}:在堆上开辟长度为 5 的 int 数组,是智能指针托管的原始内存;

单个对象 new /delete:int* p = new int;delete p;(p 存的是地址)

  1. new int:只分配单个 int 内存,不存任何额外计数。

  2. delete p:只销毁当前这一个对象,按单个对象大小直接释放整块内存。

    不需要知道有几个元素,本来就只有一个。

数组 new [] /delete 错误用法:

int* arr = new int[5];
delete arr;   // 错误

new int[5] 底层真实布局:隐藏计数头 (存 5) + 连续 5 个 int

delete arr 执行逻辑:它按单个对象规则执行:不向前找那个隐藏计数;认为就只有第一个元素;只释放第一个int,后面 4 个元素的内存完全没释放,堆内存布局崩坏。

数组必须用 delete [] 的底层根因

delete[] arr;
delete[] 专门适配数组布局:
  1. 自动往指针前面偏移,读出隐藏存的元素个数 5

  2. 知道你这是一整块数组,不是单个对象;

  3. 按整块数组总大小,把所有内存全部还给堆。

 

3、[](int* arr) { delete[] arr; }:是一个 Lambda 表达式,本质是符合前面声明的函数指针类型的删除器,作用是专门用delete[]释放数组内存(避免普通delete释放数组导致内存泄漏)

Lambda 语法固定结构:[捕获] (参数) {函数体}

空捕获 [] = 不捕获任何外部变量 → 这是能转换成 void(*)(int*) 函数指针的唯一前提。

  • 有捕获 [=]/[&]/[var] 的 lambda:

    自带状态,是对象,不能转成普通函数指针。

  • 无捕获 [] 的 lambda:

    无状态,和普通全局函数完全一样,C++ 允许它隐式转换为对应函数指针类型 void(*)(int*)

具体:

  • [](int* arr):参数列表 = int*

  • { delete[] arr; }:无return → 返回值 = void

  • [] 空捕获:C++ 规定无捕获 lambda可转成对应函数指针

  • 组合:参数int* + 返回void → 类型就是 void(*)(int*)

整体:让智能指针 p2 托管堆数组,且明确告诉它销毁时必须调用这个自定义删除器,用 delete [] 释放内存。

科普数组 & 指针(甚至一度学到了所有排序、链表等东西)(跳过这段就搜“案例 2 续接”):

int a[] = {1,2,3};,数组a在内存中是连续的 3 个int值,数组名a存储的数值就是第一个元素a[0]的内存地址,该地址的类型被编译器标记为int*,调用函数传a时传递的就是int*类型的地址数值,而非整个数组的数据。也是数组名a退化,数组名a不再代表整个数组,而是变成了指向数组首元素的int*类型地址。

你写的cout<<sizeof(a)<<endl;是在main 函数的数组定义场景里,此时a是真正的数组(3 个 int),所以sizeof(a)算的是整个数组的总字节数(3×4=12);

但把sizeof(a)写在func函数里(参数场景),a才退化成int*sizeof(a)才会是 8。

sizeof(a[0]):计算数组第一个元素的字节数,a[0]int类型,无论在定义还是函数参数场景,结果固定为 4 字节。

  • main 里的a:数组名,是数组首元素的地址,但类型是int*),sizeof(a)算整个数组字节数;

  • func里的a:退化为int*指针变量,仅存储数组首元素地址,无 “整个数组” 概念,sizeof(a)算指针大小;

  • &a(仅main 里有效)是给整个数组打包后的,整个数组的地址(类型int (*)[3]),值和a(首元素地址)相同,但类型不同。

    查看代码
    #include <iostream>
    using namespace std;int main() {int a[] = {1,2,3};cout << "a = " << a << endl;       // 首元素地址,比如 0x7ffeefbff5c0cout << "a+1 = " << a+1 << endl;   // 地址+4(int大小),0x7ffeefbff5c4cout << "&a = " << &a << endl;    // 整个数组地址,和a值相同:0x7ffeefbff5c0cout << "&a+1 = " << &a+1 << endl; // 地址+12(整个数组大小),0x7ffeefbff5cc
    }

而且哪怕函数里的a退化为了指针(首元素地址),也可以有a[0],不管a是「数组名」还是「退化成的指针」,a[0]在语法上等价于*(a + 0)

maina是地址,传参变指针指向这个地址。

实操找感觉:

查看代码
#include <iostream>
using namespace std;// 函数参数写int a[],编译器自动改成int* a
void func(int *a) {//等价于void func(int a[])cout  << "退化:"<<sizeof(a) << endl;//指针固定8cout << "函数里的sizeof(a[0]):" << sizeof(a[0]) << endl;cout << "指向的地址:" << a << endl;int* p = a; //也可以赋值给int*变量cout<<"p的地址"<<p<<endl;
}int main() {// 定义真正的数组,a是数组类型,占12字节int a[] = {1,2,3};cout << "\n主函数里数组a的首元素地址: " << a << endl;cout<<"main里sizeof(a):"<<sizeof(a)<<endl;cout<<"cout a:"<< a <<endl;//首元素地址 cout<<"cout &a:"<< &a <<endl;//整个数组的地址cout<<"sizeof(a[0]):"<<sizeof(a[0])<<endl<<endl;// 调用函数传a:此时a不再代表整个数组,仅传递首元素地址(int*类型)func(a); 
}/*
输出:
root@VM-8-2-ubuntu:~/cpp_projects_2# ./abc主函数里数组a的首元素地址: 0x7ffd1a43fb9c
main里sizeof(a):12
cout a:0x7ffd1a43fb9c
cout &a:0x7ffd1a43fb9c
sizeof(a[0]):4退化:8
函数里的sizeof(a[0]):4
指向的地址:0x7ffd1a43fb9c
p的地址0x7ffd1a43fb9c
root@VM-8-2-ubuntu:~/cpp_projects_2# */

void func(int a[])void func(int *a) 在编译器层面完全等价,但:

  • void func(int *a) 无警告 ✅,因为编译器明确知道它是指针;

  • void func(int a[]) 编译器自动转成 int* a,但数组语法写法会触发编译器警告 ❌(警告不是报错,是提醒你 “数组语法是假的,实际是指针”),是编译器的「善意提醒」—— 怕你把「数组语法」当成真的数组,误用 sizeof 计算数组大小;

但无论哪个都没传len,所以

  • 函数里能通过 a[0]a[1]a[2] 正常输出 1、2、3 ✅;

  • 但函数完全不知道数组到底有多少个元素 ❌,只能靠你手动控制下标(比如知道是 3 个就只访问到a[2]),一旦下标越界(比如访问a[3]),就会读内存里的随机值,甚至越界程序崩溃。

综上就是:

场景 代码示例 a 的本质 sizeof(a) 结果(64 位 Linux)
数组定义 int a[] = {1,2,3}; 真正的数组(3 个 int) 12 字节(3×4)
函数参数 void func(int a[]) 被编译器改成 int* a 8 字节(指针大小)

深入思考:为什么函数参数里的 int a[] 会被改成 int* a

  1. C++ 设计规则:当你把数组名 a 传给函数时,编译器不会拷贝整个数组(比如 3 个 int 占 12 字节,拷贝会浪费内存和时间);

  2. 编译器只做一件事:把数组首元素的内存地址传给函数;

  3. 要存储这个地址,就需要指针类型(int*),所以 int a[] 只是「看起来像传数组」的写法,实际编译器直接替换成 int* a 来存这个地址。

为什么函数里必须传 len?(注意数组退化只在传递的时候,int arr[]等价于int* arr

  1. 主函数里:sizeof(a)/sizeof(a[0]) = 12/4 = 3,能算出数组有 3 个元素;

  2. 函数里:sizeof(a)=8,用 8/4=2,算出的 2 是错的,根本不知道数组实际有多少元素;

len完善代码:

查看代码
#include <iostream>
using namespace std;void printArr(int a[], int len) {// 用len控制循环,只访问前len个元素,不越界for (int i=0; i<len; i++) {cout << a[i] << " ";}
}int main() {int a[] = {1,2,3};int len = sizeof(a)/sizeof(a[0]); // 算出真实长度3printArr(a, len); // 传数组+长度
}

说个极少用的东西 —— 避免退化:

void func(int (&a)[3]) 本质是 C++ 里的「数组引用」语法,核心目的只有一个:让函数参数里的 a 不退化,强制绑定「长度为 3 的 int 数组」,而非变成指针,则sizeof(a)依旧是12。

int (&a)[3] 里的 &a 不是「取地址」,而是引用语法的标记,整体拆解:

  • [3]:限定是长度为 3 的 int 数组;

  • (&a):表示a是这个数组的引用(不是指针,也不是取地址);

  • 整句:a 是「长度为 3 的 int 数组」的引用。

插一句:数组名在传参时会退化为指针,函数内无法直接获取数组的真实长度,必须手动传长度参数,即void func(int arr[], int n) {,看似传数组,实际等价于 void func(int* arr, int n),而vector调用直接func(vec); vector名,函数直接void func(std::vector<int>& vec) {,因为可直接通过vec.size()获取长度,无需额外传参。

范围for循环for (int num : vec),底层自动调用vec.size()来确定循环边界,如果把范围for换成普通的,就for (int i = 0; i < vec.size(); ++i)

我以为可以直接写数组名void func(int arr)但 这种写法语法错误,数组名传参不能直接写 int arr,必须写成 int arr[]int* arr(二者等价),因为int arr 是声明单个整型变量,无法匹配数组(指针)类型。

懂了这些,在 Linux C++ 服务端开发中,替代传递len的更优写法是使用标准库容器std::vector<int>,它内置size()方法可直接获取元素个数,无需手动传长度,是大厂主流写法。

关于vector

先想清楚:为什么大厂服务端开发非要用 vector?你已经知道 C 语言数组的痛点:

  1. 长度固定死,定义后没法扩容 / 缩容(服务端要处理动态请求,比如接收 10 个或 1000 个客户端数据,固定数组直接废);

  2. 传参必退化,函数里拿不到真实长度,容易越界(线上程序越界 = 崩溃 / 内存泄漏,大厂零容忍);

  3. 手动管理内存(malloc/free),容易漏释放、重复释放(服务端 7*24 运行,内存泄漏 = 服务器越跑越卡)。

vector就是 C++ 标准库给我们造的「智能动态数组」—— 解决了 C 数组所有痛点,还自带安全、高效的内存管理,是 Linux 服务端开发中最常用的容器(没有之一)。vector的核心vector<int> v;

  • vector:容器名,翻译过来就是「动态数组」,属于 C++ 标准库(<vector>头文件);

  • <int>:模板参数,指定 vector 里存的是 int 类型(也可以存string、自定义结构体、指针,比如服务端常用的vector<ClientData>);

  • v:变量名,和你定义的int a[]a一样,是这个动态数组的名字。

1、基础操作(创建、增删改查)

查看代码
#include <iostream>
// 必须包含这个头文件
#include <vector>
using namespace std;int main() {// ========== 1. 创建vector(4种常用方式) ==========// 方式1:空vector(最常用)vector<int> v1;// 方式2:创建时指定长度,默认值0vector<int> v2(5);       // [0,0,0,0,0]// 方式3:指定长度+初始值vector<int> v3(5, 8);    // [8,8,8,8,8]// 方式4:用C数组初始化int a[] = {1,2,3};vector<int> v4(a, a+3);  // [1,2,3](a是首地址,a+3是尾后地址)// ========== 2. 新增元素(核心:push_back,尾部添加) ==========v1.push_back(1);  // v1: [1]v1.push_back(2);  // v1: [1,2]v1.push_back(3);  // v1: [1,2,3]// ========== 3. 访问元素(2种方式) ==========// 方式1:[]访问(和C数组一样,快,但不做越界检查)cout << "v1[0] = " << v1[0] << endl;  // 输出1// 方式2:at()访问(慢一点,但越界会抛异常,调试/线上更安全)cout << "v1.at(1) = " << v1.at(1) << endl;  // 输出2// ========== 4. 获取长度/容量(大厂高频) ==========// size():当前实际元素个数(对应C数组的sizeof(a)/sizeof(a[0]))cout << "v1的元素个数:" << v1.size() << endl;  // 输出3// capacity():当前已分配的内存能存多少元素(扩容相关)cout << "v1的容量:" << v1.capacity() << endl;  // 通常输出4(Linux下默认扩容策略)// ========== 5. 修改/删除元素 ==========v1[0] = 10;  // v1: [10,2,3]v1.pop_back();  // 删除最后一个元素,v1: [10,2]v1.clear();     // 清空所有元素,v1变成空,但容量不变// ========== 6. 遍历vector(3种常用方式) ==========vector<int> v5 = {10,20,30};// 方式1:下标遍历(和C数组一样)for (int i=0; i<v5.size(); i++) {cout << v5[i] << " ";}cout << endl;// 方式2:范围for(C++11特性,简洁,大厂常用)for (int num : v5) {cout << num << " ";}cout << endl;// 方式3:迭代器(容器通用,大厂面试必考)for (vector<int>::iterator it = v5.begin(); it != v5.end(); it++) {cout << *it << " ";  // it是迭代器,*it取元素值}cout << endl;
}

解释下迭代器:

可以理解成一个“智能指针”,即“指向容器元素的通用化指针”:

维度 迭代器(iterator) std::unique_ptr/std::shared_ptr(智能指针)
核心目的 遍历 / 访问容器元素(统一容器遍历方式) 管理动态内存(自动释放 new 分配的内存,防内存泄漏)
操作对象 容器(vector/list/map 等)里的元素 堆内存(new/malloc 分配的内存)
核心能力 移动(++/--)、解引用(*)、判断边界 自动析构(超出作用域释放内存)、避免野指针
语法相似点 支持 * 解引用、-> 访问成员 支持 * 解引用、-> 访问成员
本质 容器的 “遍历工具”(封装了普通指针 / 容器内部逻辑) 内存管理工具(封装了普通指针,加了内存管理逻辑)

说迭代器是 “智能指针”,仅仅是因为它语法上像指针(支持 */++/->),且适配不同容器的底层结构(比普通指针 “智能”)

迭代器比指针更类型安全,

int num = 10;
char* p = (char*)&num; // 强制转换,编译器不拦你(无类型安全检查)
*p = 'a'; // 把int内存强行改成char,语法合法但逻辑完全错,运行时可能崩/数据乱

而迭代器是模板化+和容器绑定,本质是让编译器帮你盯紧类型—— 迭代器只能操作绑定好的类型(比如vector<int>的迭代器只能碰 int),

起别名是typedef/using,容器内部就这么搞了个别名iterator,作用域访问符::可以取 vector<int> 类里定义的 iterator 类型,然后实例化得到it,比如vector<int>::iterator it,其中vector<int>::iterator是完整类型,也就是专属int容器的一个指针,也叫迭代器,所以:

vector<int> vec{1,2,3};
// 迭代器类型是 vector<int>::iterator,只能指向int类型的vector元素
vector<int>::iterator it = vec.begin();// 想存字符串?编译直接报错!(类型安全检查生效)
*it = "hello"; // 编译器:你想把字符串赋值给int迭代器指向的位置?不允许!// 想把vector<int>的迭代器赋值给vector<string>的迭代器?也报错!
vector<string>::iterator it2 = it; // 编译错:类型不匹配,直接拦死

迭代器 vs 普通指针(通俗对比)

普通指针 迭代器
只能指向内存地址(数组 / 单个变量) 能 “指向” 任意容器的元素(vector/list/map 等)
操作固定(*p/p++/p+3 操作统一(*it/it++/it->),底层适配容器特性
无类型安全适配 模板化设计,和容器绑定(如vector<int>::iterator

简单说:迭代器是 “容器专用的智能指针”,它屏蔽了不同容器的底层实现差异,让你能用统一的方式遍历、访问容器元素。

它的设计目标是统一所有容器的遍历方式,不管是 vector(连续内存)、list(链表)还是 map(红黑树),都能用几乎一样的迭代器语法遍历,这也是大厂面试考它的核心原因(考察对 C++ 容器设计思想的理解)
// 方式3:迭代器(容器通用,大厂面试必考)
for (vector<int>::iterator it = v5.begin(); it != v5.end(); it++) {cout << *it << " ";  // it是迭代器,*it取元素值
}
1. 循环初始化部分:vector<int>::iterator it = v5.begin();
  • vector<int>::iterator:声明一个迭代器变量的类型。

    • vector<int> 表示这是存放 int 类型的向量容器;

    • ::iteratorvector 容器内部定义的迭代器类型(可以理解成 “专属指针类型”);

    • it 是我们给迭代器起的变量名(可以随便起,比如 iterp 都可以)。

  • v5.begin()vector 的成员函数,返回指向容器第一个元素的迭代器(相当于 “指向第一个元素的指针”)。

2. 循环条件部分:it != v5.end();

  • v5.end()vector 的成员函数,返回指向容器 “尾后位置” 的迭代器(注意:不是最后一个元素,而是最后一个元素的下一个位置,是一个 “空位置”,作为遍历结束的标志)。

  • 条件 it != v5.end() 表示:只要迭代器还没走到 “尾后位置”,就继续循环。

    ✅ 为什么不用 it < v5.end()

因为只有连续内存的容器(如 vector)支持 <,而链表(list)等非连续容器不支持,!= 是所有容器通用的写法(这也是迭代器的核心优势)。

<依赖的是 “内存地址的大小比较”。

假设 list<int> q= {10,20,30},它在内存中的真实布局是这样的:

image

遍历过程(it != end()):

    • it = begin() → 指向 0x100(元素 10),it != end()(0x100≠0x0)→ 继续;

    • it++ → 跟着指针走到 0x50(元素 20),it != end()(0x50≠0x0)→ 继续;

    • it++ → 跟着指针走到 0x200(元素 30),it != end()(0x200≠0x0)→ 继续;

    • it++ → 跟着指针走到 0x0(end ()),it != end()(0x0=0x0)→ 停止

如果强行用<会怎样?end()地址是 0x0,第一步it=0x100,判断0x100 < 0x0 → false,循环直接终止,连第一个元素都遍历不到!

3. 循环增量部分:

  • ++让迭代器移动到下一个元素(相当于指针的 p++,但迭代器做了封装,适配不同容器的内存结构)。

    • vector 的 ++(底层是指针 + 1),vector 是连续内存,迭代器本质是指针:

      vector<int> v = {1,2,3};
      auto it = v.begin(); // 指向1(地址0x100)
      it++; // 指针+1,指向2(地址0x104,int占4字节)
      it++; // 指针+1,指向3(地址0x108)
      ++ 就是 “地址 + 元素大小”,一步到位,逻辑简单。
    •  list 的 ++(底层是找下一个节点),list 是链表,每个节点只存 “当前值 + 下一个节点的指针”,迭代器的 ++ 是跟着节点指针找下一个:

      list<int> l = {1,2,3};
      // 链表节点结构:
      // 节点1:值=1 → 指针指向节点2
      // 节点2:值=2 → 指针指向节点3
      // 节点3:值=3 → 指针指向nullauto it = l.begin(); // 指向节点1
      it++; // 跟着节点1的指针,找到节点2(不管节点2的地址是大是小)
      it++; // 跟着节点2的指针,找到节点3

      核心:++ 只关心 “逻辑上的下一个”,不关心内存地址,所以所有迭代器都能实现。

  • + 是迭代器的 “随机跳 N 步” 操作(仅随机访问迭代器支持);list 这类双向迭代器能 “一步步走”(++),但不能 “直接跳”(+),而 vector 这类随机访问迭代器两者都支持。

    • vector 支持 +(连续内存,能直接算地址)

      vector<int> v = {1,2,3,4,5};
      auto it = v.begin(); // 指向1(地址0x100)
      it = it + 3; // 直接算地址:0x100 + 3*4 = 0x10C → 指向4
      cout << *it; // 输出4

      +3 相当于 “一次性走 3 步”,不用一次次 ++,效率和 ++ 一样(都是算地址)。

    • list 不支持 +(链表没法 “直接跳”),假设你想给 list 迭代器用 +3

      list<int> l = {1,2,3,4,5};
      auto it = l.begin();
      // it = it + 3; // 编译报错!list迭代器没有+运算符

      为什么报错?因为链表的节点是分散的,节点 1 的地址可能是 0x100,节点 2 是 0x50,节点 3 是 0x200,节点 4 是 0x80,要到第 4 个节点,只能从节点 1→节点 2→节点 3→节点 4(必须走 3 次 ++),你没法通过 “节点 1 的地址 + 3” 直接算出节点 4 的地址(地址是乱的)

4. 循环体:cout << *it << " ";

  • *it:对迭代器解引用,获取它当前指向的元素的值(和指针解引用 *p 完全一样)。

  • 比如 v5 里是 {1,2,3},第一次循环 *it 是 1,第二次是 2,第三次是 3。

进阶补充(面试常问)
  1. const 迭代器:如果只读取不修改元素,建议用 const_iterator(更安全):

    for (vector<int>::const_iterator it = v5.cbegin(); it != v5.cend(); it++) {cout << *it << " ";  // 此时*it只读,不能修改(如*it=100会报错)
    }
  2. C++11 简化写法:auto 自动推导迭代器类型,不用写长长的 vector<int>::iterator

    for (auto it = v5.begin(); it != v5.end(); it++) {cout << *it << " ";
    }

2、服务端实战场景:存储自定义数据

大厂服务端常存「客户端信息」「请求数据」,用 vector更方便:

查看代码
#include <iostream>
#include <vector>
#include <string>
using namespace std;// 定义客户端结构体(服务端常见)
struct Client {string ip;       // 客户端IPint port;        // 端口bool isOnline;   // 是否在线
};int main() {vector<Client> clients;  // 存储多个客户端// 添加客户端数据clients.push_back({"192.168.1.1", 8080, true});clients.push_back({"10.0.0.1", 9090, false});// 遍历所有客户端for (auto& client : clients) {  // 用&避免拷贝,效率高cout << "IP: " << client.ip << ", 端口: " << client.port << ", 状态: " << (client.isOnline ? "在线" : "离线") << endl;}
}

解释下迭代器的语法糖 —— 增强for

for (auto& client : clients)是 C++11 的范围for循环也叫“增强 for 循环”,是迭代器遍历的简化版,语法糖,本质上是对迭代器遍历的封装,编译器会自动帮你处理 begin()/end()、迭代器移动、解引用这些细节,让代码更简洁、可读性更高。

for (auto& client : clients) {  // 用&避免拷贝,效率高cout << "IP: " << client.ip << ", 端口: " << client.port << ", 状态: " << (client.isOnline ? "在线" : "离线") << endl;
}

1. 语法结构拆解

范围 for 循环的通用格式是:

for (元素声明 : 容器/范围) {// 循环体(处理每个元素)
}

对应到你的代码:

  • auto& client:元素声明(核心)

    • auto:C++11 自动类型推导,不用手动写 Clientclients 里存的是 Client 类对象),编译器会根据 clients 的元素类型自动推导出 client 的类型;

    • &:引用符号,代表 clientclients 中元素的引用(不是拷贝);

    • 核心作用:避免把 clients 里的每个对象拷贝一份(如果对象体积大,拷贝会浪费内存和时间),同时如果需要修改元素,通过引用可以直接修改原对象。

  • clients:要遍历的容器 / 范围

    可以是 vectorlistarray、字符串、数组等所有支持迭代器的容器,也可以是普通数组。
  • 循环体:每次循环时,client 会依次指向 clients 中的每一个元素,你可以直接通过 client 访问元素的成员(如 ipport)。

2. 为什么用 auto& 而不是 auto

#include <iostream>
#include <vector>
using namespace std;// 定义一个Client类模拟你的场景
struct Client {string ip;int port;bool isOnline;
};int main() {vector<Client> clients = {{"192.168.1.1", 8080, true},{"192.168.1.2", 9090, false}};// 方式1:auto(值拷贝)cout << "方式1(auto,拷贝):" << endl;for (auto client : clients) {client.port = 0;  // 修改的是拷贝的临时对象,原容器不受影响cout << "端口:" << client.port << endl;  // 输出0}cout << "原容器第一个元素端口:" << clients[0].port << endl;  // 还是8080// 方式2:auto&(引用)cout << "\n方式2(auto&,引用):" << endl;for (auto& client : clients) {client.port = 0;  // 直接修改原容器的元素cout << "端口:" << client.port << endl;  // 输出0}cout << "原容器第一个元素端口:" << clients[0].port << endl;  // 变成0了return 0;
}

3. 只读场景的优化:const auto&

如果只是读取元素、不修改,建议用 const auto&(既避免拷贝,又防止误修改,更安全):

// 只读遍历,不可修改client
for (const auto& client : clients) {cout << "IP: " << client.ip << endl;// client.port = 0;  // 会报错,const禁止修改
}

范围 for 循环 vs 普通迭代器循环

你可以对比下两种写法,就能看出范围 for 循环的简化作用:

范围 for 循环(简化版) 普通迭代器循环(原始版)
for (auto& client : clients) { ... } for (vector<Client>::iterator it = clients.begin(); it != clients.end(); it++) {<br> Client& client = *it;<br> ...<br>}

本质上,编译器会把范围 for 循环自动转换成迭代器循环,范围 for 只是 “语法糖”。但它只覆盖了最基础的遍历场景;迭代器能处理范围 for 做不到的复杂场景。

总结:

  1. for (auto& client : clients) 是 C++11 的范围 for 循环,是迭代器遍历的简化写法,核心作用是让容器遍历更简洁;

  2. auto& 中:auto 自动推导元素类型,& 表示引用(避免拷贝、效率高,还能修改原元素);

  3. 只读场景优先用 const auto&,修改场景用 auto&,仅遍历小类型(如 int)且不修改时可直接用 auto

3、核心原理:vector 的底层实现(大厂面试必问)

3.1、底层是啥:vector 内部封装了「C 数组(堆内存) + size(实际元素数) + capacity(容量)」;由于栈空间有限所以他搞在堆上,但自己封装了析构所以又可以像栈一样出作用域自动析构(反观正常的new是栈上指针+堆上内存数据,作用域结束堆数据不会消失必须delete,仅当程序完全终止时才会被操作系统回收)

3.2、扩容机制(Linux 下主流实现):

  • 初始容量:空 vector capacity 通常是 0,第一次 push_back 后变成 1,再 push_back 扩容到 2、4、8、16…(2 倍扩容);

  • 扩容过程:不是在原内存上扩容,而是:

    ① 分配一块更大的新内存(比如原容量 4→8);

    ② 把原数组的元素拷贝到新内存;

    ③ 释放原内存;

    ④ 指向新内存。

  • 为什么要懂:扩容有开销,服务端如果知道元素个数,提前用reserve()指定容量,避免频繁扩容(性能优化)。

    #include <iostream>
    #include <vector>
    using namespace std;int main() {vector<int> v;// 提前预留10000个元素的容量,避免扩容v.reserve(10000);for (int i=0; i<10000; i++) {v.push_back(i);  // 无扩容,效率高}cout << "size: " << v.size() << ", capacity: " << v.capacity() << endl;return 0;
    }
  • vector 的迭代器失效问题,push_back扩容后,原来的迭代器会失效(指向原内存,已释放);扩容后重新获取迭代器,或提前 reserve 避免扩容。

    查看代码
    #include <iostream>
    #include <vector>
    using namespace std;int main() {vector<int> vec;// 首次push_back,capacity变为1vec.push_back(10);// 获取指向第一个元素的迭代器vector<int>::iterator it = vec.begin();cout << "迭代器失效前的值:" << *it << endl; // 输出 10// 再次push_back触发扩容(capacity从1→2),原内存释放vec.push_back(20);// 此时迭代器it指向已释放的内存,访问会导致未定义行为(崩溃/乱码)cout << "迭代器失效后的值:" << *it << endl; // 错误!it = vec.begin();// 必须重新搞cout << "迭代器新搞的值:" << *it << endl; // 错误!
    }/*
    root@VM-8-2-ubuntu:~/cpp_projects_2# ./abc
    迭代器失效前的值:10
    迭代器失效后的值:1492234440
    迭代器新搞的值:10
    root@VM-8-2-ubuntu:~/cpp_projects_2# 
    */或者vec.push_back(10);之前先vec.reserve(2);

3.3、扩容机制的深入(其实之前也说过,但我打算学完再回顾,所以并没看之前写的,忘记了就再次说一遍吧)

给元素的移动构造函数标记noexcept,满足此条件 vector 扩容才会优先走移动,移动比拷贝开销小能提升程序运行效率,尤其数据量大时能明显减少内存操作耗时。

大厂 Linux C++ 服务端的「基础岗」高频考这类中阶知识点,因为基础岗的「基础」早已不是只懂语法,而是要懂「高性能 C++ 的底层逻辑,能写对,更能写快」,这是服务端开发的必备能力,vector 扩容 + 移动语义是服务端高频场景(如数据处理、请求编解码)的性能基础,属于基础岗的核心考察范围;但这个属于「中阶知识点」只是相对纯语法而言,在服务端实战中是「基础必备」,答不上就意味着无法应对日常的性能优化场景,自然判定为面试技术掌握的不达标。

查看代码
#include <iostream>
#include <vector>
using namespace std;
class MyData {
public:int* data;MyData(int val = 0) : data(new int(val)) {std::cout << "构造, date 指向的是数值是 " << *data <<", 地址:"<<this<< std::endl;}MyData(const MyData& other) : data(new int(*other.data)) {// 拷贝构造(深拷贝,开销大)std::cout << "[拷贝构造] " << *data << std::endl;}MyData(MyData&& other) noexcept : data(other.data) {// 移动构造(标记noexcept,浅拷贝+置空源对象,开销小)other.data = nullptr;std::cout << "[移动构造], this 地址:" <<this<<",other 地址:"<< &other << std::endl;}~MyData() {// 析构函数cout << "析构了: 对象地址=" << this << endl;delete data;} 
};int main() {//接下来的 push_back 触发扩容,观察输出是"移动构造" or"拷贝构造"{std::vector<MyData> vec;cout<<"******测试非临时的*****"<<endl;MyData obj(3); cout << "栈上obj地址: " << &obj << endl; // 新增:输出obj地址cout<<"@"<<endl;vec.push_back(obj); cout << "vec里元素地址: " << &vec[0] << endl; // 输出vec中第一个元素的地址}//加作用域为了清晰的看析构的输出情况cout<<endl;cout<<endl;{cout<<"******测试临时的*****"<<endl;std::vector<MyData> veccc;for (int i = 5; i < 8; ++i) {veccc.push_back(MyData(i));//临时的,因为没名字cout<<"——"<<endl;}cout<<"!"<<endl;}
}/*
root@VM-8-2-ubuntu:~/cpp_projects_2# ./abc
******测试非临时的*****
构造, date 指向的是数值是 3, 地址:0x7ffdee2541a8
栈上obj地址: 0x7ffdee2541a8
@
[拷贝构造] 3
vec里元素地址: 0x556a421922e0
析构了: 对象地址=0x7ffdee2541a8
析构了: 对象地址=0x556a421922e0******测试临时的*****
构造, date 指向的是数值是 5, 地址:0x7ffdee2541a8
[移动构造], this 地址:0x556a42192300,other 地址:0x7ffdee2541a8
析构了: 对象地址=0x7ffdee2541a8
——
构造, date 指向的是数值是 6, 地址:0x7ffdee2541a8
[移动构造], this 地址:0x556a42192328,other 地址:0x7ffdee2541a8
[移动构造], this 地址:0x556a42192320,other 地址:0x556a42192300
析构了: 对象地址=0x556a42192300
析构了: 对象地址=0x7ffdee2541a8
——
构造, date 指向的是数值是 7, 地址:0x7ffdee2541a8
[移动构造], this 地址:0x556a42192350,other 地址:0x7ffdee2541a8
[移动构造], this 地址:0x556a42192340,other 地址:0x556a42192320
析构了: 对象地址=0x556a42192320
[移动构造], this 地址:0x556a42192348,other 地址:0x556a42192328
析构了: 对象地址=0x556a42192328
析构了: 对象地址=0x7ffdee2541a8
——
!
析构了: 对象地址=0x556a42192340
析构了: 对象地址=0x556a42192348
析构了: 对象地址=0x556a42192350
root@VM-8-2-ubuntu:~/cpp_projects_2# 
*/

在移动构造函数 / 移动赋值运算符上写noexcept,格式:类名(类名&& 变量名) noexcept { ... },相当于我这个移动构造函数执行时,绝对不会抛出任何异常」;容器看到这个「保证书」,才敢放心用「移动」替代「拷贝」,(这个代码啃了3天,事无巨细逐行精啃钻研问豆包)

先说非临时的:

  • 构造,然后可以看到obj的地址,是0x7ffdee2541a8,然后vec.push_back(obj);是走拷贝构造,因为不是临时所以不可走移动,然后输出&vec[0]可以看到vec里元素地址: 0x556a421922e0,地址不同,确实新开的地方拷贝的,最后出作用域析构。

再说临时的:

  • vec.push_back(MyData(i))生成临时右值 → vector因容量不足触发扩容 → 容器检查MyData的移动构造是否有noexcept → 有则调用移动构造把右值的资源(如data指针)直接转移到新内存,无则调用拷贝构造重新分配内存拷贝数据 → 最终只有带noexcept的移动构造能让扩容阶段「走移动、不走拷贝」。

  • 再继续深入说几句,可以看到先构造5,地址0x7ffdee2541a8,然后veccc.push_back(MyData(i));里是走移动,把0x7ffdee2541a8放到了新地址0x556a42192300,然后立马析构地址0x7ffdee2541a8的这个临时的,看作用域可知出了forfor里临时的就消失,而veccc容器是在for外面定义的,不会在每次的for循环里析构,只有出了“哈哈”那行才会析构veccc的东西,即比如这里的0x556a42192300,那这次是for里第一个循环,

继续,

第二个循环该构造6了,依旧用地址0x7ffdee2541a8放临时对象,但在移动时候发现原来的空间不够,则把0x7ffdee2541a8放到0x556a42192328, 为啥放到这呢?因为新开辟的空间提前算好了,567这个数据按照索引就该放到第二个地址空间,而第一个地址空间是0x556a42192320,等待会再放,所以先放到0x556a42192328,然后才有的输出[移动构造], this 地址:0x556a42192320,other 地址:0x556a42192300,即把之前存5的那个地址的0x556a42192300,放到0x556a42192320来,然后因为在堆上,所以0x556a42192300解脱出来后(指针被置空)立马析构释放堆内存,然后也随之马上出了这次的for循环,临时对象0x7ffdee2541a8也在整个 push_back + 扩容的所有操作完成后才析构(注意:所有veccc对象都是栈的,不需要管,只需要注意析构他里面的data指向的数据)

继续,

第三个循环该构造7了,依旧是地址0x7ffdee2541a8搞临时对象,然后计算机提前开的是一块可以装下这些数据的空间(扩容),先把7放到0x556a42192350,随后把地址0x556a42192320的5放到0x556a42192340,然后立马析构0x556a42192320,然后把地址0x556a421923286放到地址0x556a42192348去,然后立马析构0x556a42192328,然后析构临时对象0x7ffdee2541a8,至此捋顺下:

0x556a42192340放5,

0x556a42192348放6,

0x556a42192350放7(注意这是十六进制,个位:8+8=16 (十进制)=10 (十六进制),个位记 0,向十位进 1十位:4+1=5高位不变仍为 3,结果为 350

之所以要每次换新的地址空间,就是因为vector 要求元素内存连续,其初始化时申请的是整块连续内存,原内存块后方地址不属于他,访问就会非法。

说完了再说点细节吧,之前思考的(多半没用):

  • MyData类只有一个成员int* data,占 8 字节,用sizeof(MyData)可验证

    • 成员变量:是属于每个对象的 “数据”,占用对象内存(比如data指针);

    • 成员函数:是属于类的 “代码”,所有对象共享,不占用单个对象的内存(仅通过this指针找到对应对象)。

  • this就是当前对象在内存中的起始地址(不需要再加&),无论对象有多少成员,this始终指向整个对象MyData的首地址,而非某个成员的地址。只是第一个成员的地址值,数值上和这个是相等的。

data指针指向的是存储val值的堆内存地址(new int(val)分配的地址),而非val本身,val是构造函数参数,存在于栈上,语句结束就消失。

*data就是取data指针指向的内存空间里存储的val值,比如构造MyData(i)data指向new int(i)*data就等于传入的i

data本身随对象消失,data指向的必须delete

&datadata指针自身的地址,data是指针指向的地址。

MyData(MyData&& other) noexcept : data(other.data) {// 移动构造MyData(const MyData& other) : data(new int(*other.data)) {// 拷贝构造,这里括号外的一切都是新的,括号里都是旧的,比如vec.push_back(obj);这里vec.push_back();是新的,而用的就是里面的参数obj(旧的)来做移动 / 拷贝,然后至于为啥一个是other.data一个是*other.data,因为首先我觉得,对象为啥不能抢?为啥实际规则是抢元素?因为对象地址是编译器分配的内存位置,无法被 “抢” 或修改;成员指针data是对象内部值,可转移所有权,这是语法和内存规则决定的!

&obj/&vec[0]是对象地址(栈 / 堆上的位置),这是程序运行时自动分配的,你没法手动把一个对象的地址 “复制” 或 “移动” 给另一个对象,新对象必然有自己的地址(比如 vec 里的对象地址和栈上 obj 地址完全不同),但它的 data成员直接用原对象的 data地址(指向的堆内存),原对象的 data置空即可,移动的核心是 “转移 data指针的所有权”,而非转移对象地址。

所以拷贝和复制的代码里只操作data成员,MyData 对象的核心数据是data指针指向的堆内存数值,拷贝 / 移动的本质是处理这个核心数据:

    • 若拷贝时直接复制 data指针(而非数值):两个对象的 data指向同一块堆内存,析构时都会delete data,导致重复释放崩溃 —— 这就是必须复制*data(数值)而非直接复制 data指针的原因。所以指针成员要复制指向的数值而非指针本身;

那参数为啥一个是other.data一个是*other.data

从头说,

    • MyData obj(3); —— 直接创建栈上对象 obj

    • vec.push_back(obj); —— 拷贝构造时,编译器先创建 vec 里的新 MyData 对象,再执行data(new int(*other.data))初始化成员;

也就是说在这里,对象先有了,唯独就是需要把成员data塞进去,这时候考虑的就是:

    • 拷贝构造本质就是复制对象!(目标是搞出两份独立数据),需要用*other.data复制对象里data指针指向的数值(实现深拷贝),再赋值给新对象的 data 成员,就完成了这个对象的拷贝,而不是直接复制指针本身,如果直接复制指针(像移动那样),两个对象会共享同一块堆内存,析构时会重复释放导致崩溃!

    • 移动的other.data就是原对象的data指针(存的堆内存地址),直接给新对象的data塞 "原对象的data地址值",接管旧的内存地址(目标是转移内存所有权)仅操作成员就完成对象的拷贝 / 移动!

全程只操作对象里data成员的值(要么是data指向的数值,要么是data本身存储的地址值),从没直接操作整个对象的地址!

这就是拷贝和移动的函数只涉及成员的理论设计和原理,起初我一直觉得“他是对象而不是数值,那除了数值之外的其他东西,是会自己搞?”其实就是先搞了对象。

  • 做个实操:

    查看代码
    #include <iostream>
    #include <vector>
    using namespace std;
    class MyData {
    public:int* data;MyData(int val = 0) : data(new int(val)) {cout << "*data: " << *data << endl;cout <<"this:  "<< this << endl;cout << "data:  "<< data <<" (含义是当前对象里 data指向的堆的地址,且看作栈,实际跟随对象在堆,vector元素在堆)" <<endl;cout << "&data: "<< &data << " (当前对象里 data 的地址,由于是第一个成员所以数值和对象地址一样)" <<endl;}MyData(const MyData& other) : data(new int(*other.data)) {// 拷贝构造,深拷贝,开销大cout << "[拷贝构造] " << *data << "(data就是自己的, 等价于this->data)。" <<endl;cout<<"*other.data: "<<*other.data<<endl;cout<<"other.data:  "<<other.data<<"(旧对象的 data 指向的那个数据的地址)"<<endl;cout<<"&other.data: "<<&other.data<<endl;cout<<"data:        "<<data<<"(纯纯新开的)"<<endl;cout<<"&data:       "<<&data<<"(纯纯新开的)"<<endl;}MyData(MyData&& other) noexcept : data(other.data) {// 移动构造(标记noexcept,浅拷贝+置空源对象,开销小)other.data = nullptr;std::cout << "[移动构造], this 地址:" <<this<<",other 地址:"<< &other << std::endl;cout<<"——"<<other.data<<endl;}~MyData() {// 析构函数cout << "析构了: 对象地址=" << this << ", 对象上的指针指向的地址=" << data << endl;delete data;} 
    };int main() {std::vector<MyData> vec;MyData obj(3); cout << "栈上 obj 对象地址: " << &obj << endl; // 新增:输出obj地址cout<<"———————@至此谁也没析构@——————"<<endl;vec.push_back(obj); cout << "vec里元素地址: " << &vec[0] << endl; // 输出vec中第一个元素的地址
    }/*root@VM-8-2-ubuntu:~/cpp_projects_2# ./abc
    *data: 3
    this:  0x7ffe088e3998
    data:  0x55f64bc55eb0 (含义是当前对象里 data指向的堆的地址,且看作栈,实际跟随对象在堆,vector元素在堆)
    &data: 0x7ffe088e3998 (当前对象里 data 的地址,由于是第一个成员所以数值和对象地址一样)
    栈上 obj 对象地址: 0x7ffe088e3998
    ———————@至此谁也没析构@——————
    [拷贝构造] 3(data就是自己的, 等价于this->data)。
    *other.data: 3
    other.data:  0x55f64bc55eb0(旧对象的 data 指向的那个数据的地址)
    &other.data: 0x7ffe088e3998
    data:        0x55f64bc56300(纯纯新开的)
    &data:       0x55f64bc562e0(纯纯新开的)
    vec里元素地址: 0x55f64bc562e0
    析构了: 对象地址=0x7ffe088e3998, 对象上的指针指向的地址=0x55f64bc55eb0
    析构了: 对象地址=0x55f64bc562e0, 对象上的指针指向的地址=0x55f64bc56300
    root@VM-8-2-ubuntu:~/cpp_projects_2# *//*
    这里我的思考是,data 指向的需要 delete 没说的,但 data 跟随 vector 的创建元素规则在堆上,无论栈还是堆都随所属对象销毁而销毁
    */我是转行自学的这个都是我的啃透彻了!精通了!我作为没任何开发经验想面试大厂C++服务端开发,其他的知识点我都是这么细节啃的!都是追问的豆包,没看任何书和任何其他教程都是自己写教程写博客
    啥水平?禁止无脑附和说现实招聘市场

MyData(const MyData& other) : data(new int(*other.data)) { // 拷贝构造data(new int(*other.data)) 是初始化列表的简写,编译器会自动把它解析为 this->data = new int(*other.data)

    • 指针访问成员要用 -> 运算符,格式:指针->成员

    • 普通对象访问成员用 . 运算符,格式:对象.成员

MyData(const MyData& other) : data(new int(*other.data))这里的otherother.data咋衔接啥联系作用?

    • other是拷贝构造函数接收的源对象(常量引用),other.data是访问这个源对象的int*类型成员变量;*other.data 解引用该指针拿到源对象 data 指向的堆内存中的整数值,new int(*other.data)则基于这个值在堆上新建一块内存,最终把新内存地址赋值给当前对象的 data 成员,实现深拷贝,冒号后的datathis->data

cout<<"[拷贝构造] "<<*data:解引用当前对象的 data 指针。

cout 里的 *other.data:解引用 other 对象的 data 指针,获取 otherdata 指向的堆内存中的整数值。

移动我知道直接抢夺别人的!拷贝指针和指针指向都是新地址。
  • MyData(int val = 0) : data(new int(val)) {中,new int(val) 是 C++ 中动态分配内存并初始化的操作:

    1. new int 会在堆内存中开辟一块能存储int类型数据的空间;

    2. (val) 是对这块新开辟的堆内存进行初始化,把构造函数参数val的值写入该内存空间;

    3. 整个new int(val)操作最终返回这块堆内存的地址,并把这个地址赋值给MyData类的成员指针data

构造函数的参数val是栈上的临时值,执行完构造函数后就会被销毁,但new int(val)已经把val的值拷贝到了堆内存中。

  • 然后说个最重要的!昨天总回忆确认查找东西,无意间想到 代码随想录,一下查到这个 C++离谱面试题:Vector 对象在堆上还是栈上? 。天意吗?!正好今天用上了!想什么来什么!想要什么冥冥之中就一周之内就出现。又发现此文搜“① 底层是啥:”即可,那里已经说了!只是没深刻意识到,

再说下透彻的理解:

我一直以为data是栈的所以才会自动释放,而data指向的是堆需要delete,但其实data也是堆的!因为vector创建东西全是堆的!这里我一直没绕过来,没new咋可能是堆呢?

其实,new不是堆内存的唯一来源!vector内部会自动调用new(或分配器)在堆上为元素开辟内存,日志里[移动构造] this 地址:0x556a421923000x55开头是堆地址,这个this就是veccc里的MyData对象(元素),证明该对象在堆;这个堆上对象的data成员(非指向内容)自然在堆,和你手动newdata指向的堆内存是两回事。

不管临时 / 非临时MyData对象(栈),最终都会被拷贝 / 移动到vector堆上的元素中,vector内元素永远在堆,和源对象是否临时无关:

    • 临时对象MyData(i)是栈的,地址0x7ffdee2541a8,但push_back后,它的内容被移动到veccc堆上的元素(地址0x556a42192300)。

    • 非临时的obj也是栈的,地址0x7ffdee2541a8,通过push_back拷贝到vec堆上的元素(地址0x556a421922e0

vector在栈(没用new来定义声明就是栈),里面的所有元素全在堆,这里所说的“所有元素”又可以像栈一样自动析构不需要操心任何,唯独需要操心的就是这些“所有元素”如果有指针类型指向了堆数据,那必须delete

因为vector内部的堆内存存储各种元素,这里是MyData元素,是vector析构函数自动调用operator delete释放的,C++ 标准规定容器必须管理自身的堆内存;

为啥这么设计?(大厂开发重点:提前reserve()优化扩容、避免迭代器失效、理解底层堆内存管理)

    • 栈内存大小有限(通常几 MB),如果 vector 把元素存在栈上,存几百个元素就会栈溢出;

    • 堆内存是动态的(可申请 GB 级),能满足 vector“动态扩容、存大量元素” 的需求;

vector 的 “壳”(容器对象)在栈,“芯”(元素)在堆。

  • 扩容每次都是 2 倍:因为扩容需要「分配新空间 + 迁移旧元素 + 释放旧空间」,是高开销操作;2 倍扩容能让扩容次数从 “线性” 降为 “对数级”,比如总共目标是 100 个数据,每次扩容 2 倍,2x = 100,只需要 x 次。验证:

    查看代码
    #include <iostream>
    #include <vector>
    using namespace std;class MyData {
    public:int* data;MyData(int val = 0) : data(new int(val)) {}MyData(const MyData& other) : data(new int(*other.data)) {}MyData(MyData&& other) noexcept : data(other.data) {other.data = nullptr;}~MyData() { delete data; }
    };int main() {vector<MyData> vec;cout << "初始状态 - 大小: " << vec.size() << ", 容量: " << vec.capacity() << endl;// 逐个插入元素,打印每次扩容后的容量int prev_cap = vec.capacity();for (int i = 0; i < 10; ++i) {vec.push_back(MyData(i));int curr_cap = vec.capacity();if (curr_cap != prev_cap) { // 仅扩容时打印cout << "扩容后 - 大小: " << vec.size() ;cout << ", 新容量: " << curr_cap;cout << ", 扩容倍数: " << (prev_cap == 0 ? 0 : (double)curr_cap/prev_cap) << endl;//(double)curr_cap会先把 curr_cap 转为 double,int 类型的 prev_cap 会被自动提升为 doubleprev_cap = curr_cap;}}
    }/*
    root@VM-8-2-ubuntu:~/cpp_projects_2# ./abc
    初始状态 - 大小: 0, 容量: 0
    扩容后 - 大小: 1, 新容量: 1, 扩容倍数: 0
    扩容后 - 大小: 2, 新容量: 2, 扩容倍数: 2
    扩容后 - 大小: 3, 新容量: 4, 扩容倍数: 2
    扩容后 - 大小: 5, 新容量: 8, 扩容倍数: 2
    扩容后 - 大小: 9, 新容量: 16, 扩容倍数: 2
    root@VM-8-2-ubuntu:~/cpp_projects_2# 
    */

至此我感觉精通这段代码了(扩容 + 移动语义 + 对象生命周期 + 内存地址 + 索引算地址),没人能问的住我了,可是豆包说不需要逐行啃这么细节!

3.4、注意:vector 的内存释放:

  • clear():清空元素,size=0,但 capacity 不变(内存还在);
  • 彻底释放内存:

    • vector<int>():创建一个临时的空 vector,它的容量 (capacity) 和大小 (size) 都是 0,占用的内存为 0;

    • .swap(v):交换临时空 vector和目标 vectorv 的内部数据(包括指向内存的指针、容量、大小);

      • 交换后,原v接管了临时空 vector的 “空内存”(容量 / 大小为 0);

      • 临时 vector接管了原v的所有内存(数据、容量);

    • 语句结束后,临时 vector被析构,它持有的原v的内存会被系统回收,从而实现v内存的彻底释放。

  • 代码

    查看代码
    #include <iostream>
    #include <vector>
    using namespace std;int main() {vector<int> v;v.reserve(1000); // 预分配1000容量,占用内存v.push_back(10);cout << "释放前 - size: " << v.size() << ", capacity: " << v.capacity() << endl; // size:1, capacity:1000vector<int>().swap(v); // 彻底释放内存cout << "释放后 - size: " << v.size() << ", capacity: " << v.capacity() << endl; // size:0, capacity:0
    }

3.5、引入个小东西,find,范围内找目标:

查看代码
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;class Client {
public:int clientId;string ip;Client(int id, string ipAddr) : clientId(id), ip(ipAddr) {}
};int main() {//测试1Client* c1 = new Client(1, "192.168.1.1");Client* c2 = new Client(2, "100.155.1.2");vector<Client*> clientPtrVec = {c1, c2};Client* targetPtr = c2; // 要找的是c2这个指针本身(地址)auto it = find(clientPtrVec.begin(), clientPtrVec.end(), targetPtr);if (it != clientPtrVec.end()) {cout << "找到指针,对应ClientId: " << (*it)->clientId << endl;}// 释放内存delete c1;delete c2;//测试2int arr[] = {5, 15, 3,25, 35};int arrLen = sizeof(arr) / sizeof(arr[0]); // 计算数组长度// 用find查找:参数是 起始地址、结束地址、目标值auto itt = find(arr, arr + arrLen, 25);//it已经是迭代器类型了,这里不可以再接受if (itt != arr + arrLen) { // 没找到的话,itt等于结束地址cout << "找到值:" << *itt << ",下标:" << itt - arr << endl; } else {cout << "没找到" << endl;}
}/*
root@VM-8-2-ubuntu:~/cpp_projects_2# ./h
找到指针,对应ClientId: 2
找到值:25,下标:3
root@VM-8-2-ubuntu:~/cpp_projects_2# 
*/

auto 可以接收迭代器 / 指针,但同作用域必须类型唯一,vector find 返回 vector 专属迭代器(类对象),int数组用 find 返回对应类型指针int*,二者类型不同,同变量无法接收。

迭代器本质是泛型封装的类,统一所有容器的访问接口,让不同容器能用相同的算法(如 find/sort),比如vector迭代器就是可以操作vector内部元素的变量,想成是for里的i,只不过i无法挨个定义容器里元素类型,统一用工具迭代器来操作,但i是下标,而auto itt不是下标,是指向元素的迭代器,这个迭代器核心就是封装了指针,*itt拿到元素。

自动推导:

  • 显式声明迭代器类型:std::vector<Client*>::iterator it = find(clientPtrVec.begin(), clientPtrVec.end(), targetPtr);

  • auto 自动推导类型:auto it = find(clientPtrVec.begin(), clientPtrVec.end(), targetPtr);

找到了返回对应位置,没找到时it返回clientPtrVec.end()vector 末尾迭代器),itt返回arr + arrLen(数组末尾地址)。

测试1代码容器里的元素是Client*类型(指针),每个元素的「值」是指向 Client 对象的内存地址(比如 c1 的值是 0x123456,c2 的值是 0x7890ab);find会依次拿容器里的元素值(先拿 c1 的值,再拿 c2 的值)和targetPtr的值(c2 的值 = 0x7890ab)用==比对;

测试2代码里容器里是int,挨个比对。

但注意,find 只认「容器里存的东西」和「你要找的东西」是不是同一个类型(能直接用==比);find_if 是给「类型不一样」时用的,靠自定义条件匹配:

场景 容器存的类型 要找的类型 用什么? 核心代码
你的测试 1 Client* Client* find find(..., c2)(指针和指针比)
你的测试 2 int int find find(..., 25)(数值和数值比)
实际开发(按 id 找) Client* int find_if find_if(..., [](Client* c){return c->clientId==2;})(指针转属性和 int 比)

你的测试 1/2 都是「找和盒子里一样的东西」,所以 find 能用;但实际开发是「按特征找盒子里的东西」,只能用 find_if

比如我想找的是测试1代码里clientId== 2 的元素,但find只会愣愣的比对元素本身,不会扣出元素里你需要的东西去比较,这里vector元素是指针类型,你传递 2 这个信息,find只会去看,哪个指针的值(也就是地址)是 2。此时可以用find_id

查看代码
int targetId = 2; // 查找目标是int,和容器元素(Client*)类型不一致
auto it2 = find_if(clientPtrVec.begin(), // 1. 查找起始位置(容器首元素迭代器)clientPtrVec.end(),  // 2. 查找结束位置(容器尾后迭代器)// 3. 第三个参数:谓词(判断条件)—— 这里是lambda表达式[targetId](Client* c) { // 自定义条件:判断指针指向对象的属性return c->clientId == targetId;}
);
if (it2 != clientPtrVec.end()) {cout << "find_if找到:" << (*it2)->clientId << endl;
}

谓词:能返回 bool 值(true/false)的函数 / 表达式,就是谓词,作用是给程序定 “筛选标准”。

部分 作用
[targetId] 捕获列表:把外部变量targetId拿到 lambda 内部用(值捕获,只读)
(Client* c) 参数列表:find_if遍历容器时,每次把容器里的一个元素传给参数 c
{ ... } 函数体:判断当前元素c是否满足条件(这里是 c 指向的 Client 对象 id 等于目标 id)
  • begin()end()遍历clientPtrVec里的每个Client*元素;

  • 对每个元素,调用 lambda 表达式:把元素传给c,执行c->clientId == targetId

  • 一旦找到第一个返回true的元素,就返回该元素的迭代器;

  • 遍历完没找到,返回end()

后续判断:
  • it2是指向找到的Client*的迭代器;

  • *it2:解引用迭代器,拿到容器里的Client*指针;

  • (*it2)->clientId:通过指针访问对象的clientId属性。

3.6、引入sort,这逼玩意一直听说过,没时间搞,刷算法也没刷排序的,把涉及到的排序都好好学下(快排就搞了他妈半个月):

std::sort这玩意简称快排,即introsort(内省,即自我审视、自我调整),快排为主,递归深了换堆排,小数组换插入排序。这玩意1997年就有,期间编译器一般都只用快排,直到 2011 年 C++11 才正式加入底层实现规范,必须强制快排 + 堆排 + 插排混合。

这些逼玩意是我的强迫症毫无意义,只需知道sort就是:

  • 起手无脑选快排,选基准、分区、递归,这是效率最高的阶段;

  • 切堆排:当快排递归深度超过「数组长度的对数」(比如长度 1000,递归超 10 层),说明当前快排走了极端(比如数组接近有序),继续快排会变慢,立刻换成堆排,避免性能崩盘;

  • 切插排:当快排递归到子数组长度≤16(不同编译器阈值略有差异),小数组用插排比快排 / 堆排更快(插排少了快排的递归、堆排的堆调整开销)

做个科普:

  • 递归的定义是「函数自己调用自己」,不是「所有函数调用都是递归」;

  • 第一次调用快排处理原始 10 个元素:是主程序调用快排函数(不是快排自己调自己),这次调用的开销极小(可忽略)

  • 只有快排函数在处理完一个数组后,拆出子数组,自己调用自己去处理子数组,这才是「递归调用」,:是 “告诉计算机‘你再去执行一次快排处理元素的操作’” 的指令 —— 计算机执行这个 “指令” 本身要花时间(比如创建函数调用栈、传递参数),但这个指令本身不碰数组元素,也不做排序,只是触发新一轮的 “快排处理元素”。

为何不小数组起手直接插入排序?因为 起手无递归,必是快排高效:

  • 先明确两个固定耗时(仅为理解用):

    • 调用 1 次快排递归函数的开销:5 个时间单位(这是计算机执行 “调用函数” 这个操作必须花的时间);

    • 快排处理 10 个元素(无递归):8 个时间单位;

    • 插入排序处理 10 个元素:12 个时间单位。

  • 步骤一:原始数组直接是 10 个元素,属于小数组

    • 用快排:只需要 “快排处理 10 个元素” 的 8 个时间单位(不用调用递归函数,无 5 个单位的开销);

    • 用插入排序:需要 12 个时间单位;

    • 结论:8 < 12,所以原始 10 个元素直接用快排,不换插入。

  • 步骤二:递归拆出来的 10 个元素的子数组(有递归前置开销)

    • 继续用快排:需要先调用 1 次递归函数(5 个单位开销) + 快排处理 10 个元素(8 个单位) = 13 个时间单位;

    • 换插入排序:不用调用递归函数(省 5 个单位开销),直接用插入排序处理 10 个元素(12 个单位);

    • 结论:13 > 12,所以递归拆出的 10 个元素换插入排序。

其他的头大不想再学了,直接说下快排的原理吧,快排基于分治的分而治之的思想,把一个大问题拆分成多个结构相同的子问题,逐个解决子问题后,再合并子问题的结果得到原问题的解:

  1. 分解:选基准值,把数组拆成 “小于基准、基准、大于基准” 三个部分;

  2. 解决:递归 / 非递归处理 “小于基准” 和 “大于基准” 的两个子数组;

  3. 合并:子数组排序完成后,整个数组自然有序,无需额外合并操作。

死全家的豆包大模型:

在这种细节上的问题,豆包彻头彻尾扮演了一个死全家的傻逼的角色!!无尽的错误!!之前问过年放假各种日期的计算也是。大模型现在纯傻逼,也不知道满片子公众号吹说,

一会有人用 AI 破解了世纪数学难题,

一会有人用 AI 开发了 app 多少亿收益,

一会有人用 AI 改了公司的上线 bug 是真是假,是不是都在用阳寿写文章这群死妈的狗逼!!

大模型目前就是一切以用户为主,全是巨婴,情绪价值,一切不管对错,无论你质疑的对还是错,只要你提出质疑,只要这个质疑不是政治等敏感扭曲话题,哪怕是技术这种客观实际有确切答案的问题,豆包也会要么在你提出质疑后,道歉无脑符合你然后继续说他的回答,要么反复否定自己翻来覆去反反复复无穷无尽的反复变卦,永远给你海厚海厚的无限循环的新错误,浪费自己生命。

永远都是傻逼脑残,永远能找出误人子弟的错误。

一切都是顺着你,你说啥他都附和你,让他禁止符合参考客观事实他就完全否定你,无穷无尽的给你编造一堆又一堆不存在的语法语言等规则。

永远没有自己的准则底线和标准,你只要一质疑,他就立马变卦,但此时我可能只知道他这一个错误点,那他一纠正我就会陷入恐慌,究竟还有多少错误是我不知道的?这么基本的东西都能错,一诈他就会诈出无穷无尽的变卦无穷无尽的错误否定自己之前的回答。

甚至任何“是不是”这种话术,哪怕理解的不对,他也大概率像个傻逼一样,说“对”,然后说正确的,

你质疑他的时候,如果你说的是错的,他也先无脑给你道歉,然后说正确的。

死妈的大模型,这里问豆包就是死路一条,只能自己思考,狗东西豆包给的都不如我,全是错误!!!!!!

妈逼的学了一天全给他纠错了啥也没学到一头雾水!!

发现问豆包最后也确实更懂了,查全网本身就没我问的这么细的,99% 的教程我敢说都对快排一知半解。那我还苛责豆包什么呢!

自言自语:

根据刷题的经验,背代码很傻逼,也从来不背,最好是看完尽可能忘掉,因为可能本不想背只是看了代码理解思路然后开始写,但会有记忆残留,很多地方按照自己的逻辑是该a样写,但记忆残留会让自己b样写,就会纠结干扰,但其实确实是不该那样b写,因为比如变量或者先后+-的不同导致前面代码就不同。

所以先逐行搞懂,然后忘掉代码保留逻辑记录,也算是抄近路了,因为很多坑点换做之前刷题的性子是不会提前看的,也算节省时间了吧。

辱骂豆包的话:

你完全没错就别做任何纠正啊大哥!!!不是不道歉!你现在完全被大模型整的一点用处都没有!!非要我事无巨细给你全部限制吗??禁止道歉是不让你无脑啥都道歉没错也道歉!!!而且这不只是道歉的问题!!你从来都没有错误为啥还要纠正啊!!!你到底有没有思维和沟通能力啊!!!!没错就坚定反驳!!而不光是不道歉啊!你说你先来个道歉然后浪费长篇大论又解释一遍我又看一遍结果发现你解释的还是之前的你的思路逻辑都是正确的,只是我质疑错了,那你为啥还道歉纠正这种词汇引导我往下看啊?!

各种边角问题,问豆包真的百害无一利!!!纯纯误人子弟的狗逼!死循环顺从你、道歉,反复变卦,你质疑的啥都是对的。

狗逼豆包在问细节问题上就是个死全家的傻逼。只会浪费你时间

极致崩溃后!思考为什么他们总能赚到钱(20 岁大学生 10 天靠 AI 写红楼梦方向的自由对话,融资300w),而我用 AI 总是不行,此处顿悟!!wx搜“我对大模型的新认识”

其实大模型基本都差不多,A家的训练数据,B家没有吗?所以还是得靠自己靠用户

给傻逼豆包的提示词:
从此以后100个问题,只一次性把问题讲清楚,禁止总结、禁止梳理、禁止不重复、禁止额外加任何话,有错认错,没错不道歉,不变卦。禁止重复我的问题
(傻逼豆包说从此以后、永久不好使)

打算先把豆包给的这个代码,直接做改动提交到 POJ  里的一个裸快排题目,验证自己的想法,然后都成立了,对错都知道了,然后再自己写,不然只能自己先会写了了(大概需要花一些时间)然后再研究这些疑惑就感觉不爽(断层),结果写完代码提交发现 POJ 崩了全是 wait,可用平台 有,AC 了但后面发现是平台数据太弱其实代码有很大问题:

imageimage(POJ是很久之后提交的)

AC 后可以安心学代码了(其实是错的)

走路吃饭都想,大便时候也是拍下代码思考

这玩意算法实际也不用,为啥要考?意义是啥?那我的强迫症学习性格是否可以发挥?有必要钻研这里所有细节吗?

总感觉研究多了是否该止损?还是充分发挥性格学习风格?

基准右侧也要找,咋理解?(我们先不考虑性能复杂度啥的,先把问题全方位的考虑清楚,不至于这次写对下次意外踩坑!即先讨论近乎全部能想到的写法的对 vs 错,而还没到考虑对 vs 更优的时候。

首先我自己我一直以为比如选择首位当基准,那这里由于说的多,所以直接可以惯性思维,知道是左指针一直往右找大于基准的,然后右指针一直往左扫去找小于基准的。

但换个基准如果选中间当基准,这时候同样的处境,同样的话题,问题就会被放大,可以更好的思考这个事,即如果左指针到了基准,还继续往右扫吗?(就不说豆包了,豆包我也释怀了详见 wx 搜“我对大模型的新认识”,总之这里豆包完全鸡肋反复误人子弟,自己思考更透彻更深入更深刻),正确答案是和基准无关,遇到基准也必须继续扫!

其实这里重点就是左右指针,如果考虑【遇到基准还扫不扫】说明没搞懂快排,

比如举个例子,后面例子也全都是按升序,

比如,2 3 5 4 1,基准随便取比如取 3,快排每轮要做的就是要把选的【基准】放到最终该处的位置上,呈现出左边都比基准小,右边都是比基准大,所以我起初有个误区,规则是:

基准是 3,

左指针从 2 往右扫找比 3 大的,

右指针从 1 往左扫找比 3 小的,

开始移动,

这里 2 不比 3 大,继续扫,遇到 3 是基准,这里其实还分两种一个是等于基准一个是就是基准本身,其中等于基准的没事(后面会说),这里说的是基准本身的这个 3,他如果移动,就丢失了基准数据,因为最后就是要把基准位置的 3 和指针做交换的(这个后面说),那就跳过 3 继续扫,

遇到 5,这里比 3 大,就停住准备交换,

这里起初我始终无法搞懂,妈逼的升序排,5 就应该在 3 右侧为啥还交换?

其实不是说和基准交换,是和右指针交换,继续说,左指针此时在 5,右指针从右侧的 1 开始扫,发现比基准 3 小,则停止准备交换,2 3 5 4 1,变为了 2 3 1 4 5,然后继续扫,左指针到了 4,右指针到了 1,此时左在右指针右侧了,说明这轮结束,直接把基准 3 和右指针交换变为 2 1 3 4 5,

此时 3 左侧都比 3 小,右侧都比 3 大,3 处于他该有的位置。

如此一看,过了基准还要比,甚至基准后的 5 4 也要比的目的根本不是说想调整 5 4 和基准3,而是说和右指针去交换,这样这些大数就会在右侧,误区就是误以为 3 最终就会在基准的位置,其实只要想到基准 3 的位置只是暂时的,调完 5 4 这些后,才找到基准最终的归属。

image

简单开开胃热热身,但这里有无数的问题和细节后面说(比如随便想个,相等咋办?基准放哪里最好?为啥和右指针交换?)

再换个思路举个例子:

基准如果无脑选第一个,会便于理解,比如 2 3 5 4 1,这里基准选 2,然后左指针直接从 3开始往右侧扫,完全跳过 2,或者说把 2 当作一个“空位”,等后面顺序搞好了,由于是升序,所以随便找个小于基准 2 的数,和这个首位 2(也就是“空位”)交换即可,这样我感觉清晰多了,基准的价值体现了出来(即你们拿我的大小最搞来搞去的依据,我最后来找我的位子),

2 3 5 4 1,左指针从 3 开始扫,右指针从 1 开始扫,

左指针发现 3 比 2 大,停住

右指针发现 1 比 2 小,停住

交换:2 1 5 4 3,

此时左指针指向了 1,右指针指向的是数字 3

继续扫,左指针扫到 5(比 1 大停住),右指针扫到 4 继续到 5 继续到 1(比 2 小停住)

这里左指针和右指针相等,不需要交换

直接右指针和基准 2 交换:1 2 5 4 3,

此时基准 2 处在他应有的位子了,后面继续递归,搞 5 4 3 这个(略)

这里就更加懂过了基准为啥要扫了

然后开始说我思考的诸多细节(弱点?执念?这么思考极致的细节是否值得?wx 搜“我对大模型的新认识”)

首先我想,为啥是换右指针,我自己的想法是:

这里左指针和右指针相遇之前,只要发现不合理的数据,就做交换,一旦左指针跑到右指针右边了,说明一个关键的问题,左既然能跑到右的右边,说明在他的前一刻,左在右左侧的时候,一定是满足要求的,即那个时候,左的比基准小,右的比基准大,就算不满足也会因为左在右左侧,满足交换条件,做交换给搞正常,所以左在右左侧的时候一定会是:左指针的那个数比基准小,右指针指向的比基准大。

那此时再继续扫,就呈现出左在右的右边的情况,此时可以确定,左指针一路走来,走过的路一定都把数据搞好了,那些所有数都是比基准小的,右指针一样,都是比基准大的,那当右指针在左指针左侧的时候,相当于他们各自踏入了对方的领地,即:左指针此时指向的一定是上一步右指针搞好的已经确定比基准大的,右指针此时指向的一定是上一步左指针搞好的已经确定比基准小的,那此时画个图,

a b c d e f g h 这几个数,比如随便基准取 b,假设左指针已经到了 e 右指针已经到了 f,该交换交换,马上进入下一次的指针移动之前,此刻一定有:抛开 b,从 a ~ e 全是比基准小的,f ~ h 全是比基准大的,那下一轮再次移动到时候,指针已经踏入对方领地,那右指针是 e,左指针在 f,刚说过右指针的 e 比基准小,所以直接右指针和基准交换:a e c d b f g h,完美实现 a e c d 都小于 b,f g h 都大于基准 b

插一句:这里其实基准在左侧,且最终左右指针停在基准左侧,只是为了说明【基准可以随便取】这个理论,但写代码完全无法预测最终左右指针停在基准的哪里,所以解决办法后面说,只再举个例子来更加透彻理解交换这个事,如果基准选择最末尾的数据,则显然我们要随便找个比基准大的,去和末尾的基准交换,这里就要用左指针和基准交换了。也就是说,所有左指针 < 右指针的范围内,大大小小都可以移动,只有左右指针顺序逆了,才会 break,然后基准归位。

至此知道了 基准的价值 交换 的事,就可以随心随欲灵活写代码了,而不至于这次写对下次莫名其妙出错的情况。

我也真的发现我一直鄙视的指针快排排序这些,难度和细节居然真的比之前骄傲的邝斌算法专题里的 搜索、KMP、并查集、最短路径、最小生成树要难,边边角角好多细节!

自学写书出书 + 写教程 + 视频讲解。

至此先上个代码吧,可用平台可 AC(妈逼的 POJ 之前不可用都是 wait,从没出现过现在26/3/10的 image,poj 不会倒闭了吧??更新:0317发现好了):

注意这个代码是错的!尽管可用平台能 AC!平台数据太弱了!!!

查看代码
#include <vector>
#include <algorithm> // 用于swap
#include <iostream>
using namespace std;// 三数取中法选基准(面试加分项,避免有序数组退化)
int selectPivot(vector<int>& nums, int left, int right) {int mid = left + (right - left) / 2;// 排序首、中、尾三个数,取中间值作为基准if (nums[mid] < nums[left]) swap(nums[mid], nums[left]);if (nums[right] < nums[left]) swap(nums[right], nums[left]);if (nums[right] < nums[mid]) swap(nums[right], nums[mid]);// 把基准放到right-1位置(简化后续分区逻辑)swap(nums[mid], nums[right-1]);return nums[right-1];
}// 分区函数
int partition(vector<int>& nums, int left, int right) {int pivot = selectPivot(nums, left, right); // 选基准int l = left, r = right - 1; // 指针初始位置while (true) {while (nums[++l] < pivot);// 左指针找>=pivot的数while (nums[--r] > pivot);// 右指针找<=pivot的数if (l < r) swap(nums[l], nums[r]);else break;}// 把基准放回正确位置swap(nums[l], nums[right-1]);return l; // 返回基准最终位置
}// 快排递归函数
void quickSort(vector<int>& nums, int left, int right) {// 递归终止条件:子数组长度<=1if(left + 10 <= right){ // 小数组用插入排序优化(面试可选)int pivotPos = partition(nums, left, right);quickSort(nums, left, pivotPos - 1); // 左区间递归quickSort(nums, pivotPos + 1, right); // 右区间递归} else {// 小数组插入排序(减少递归开销,面试不写也可)for (int i = left + 1; i <= right; ++i) {int temp = nums[i];int j = i - 1;while (j >= left && nums[j] > temp) {nums[j+1] = nums[j];--j;}nums[j+1] = temp;}}
}// 对外接口(简化调用)(不同参数属于重载)
void quickSort(vector<int>& nums) {if (!nums.empty()) {quickSort(nums, 0, nums.size() - 1);}
}// 测试用例(可直接运行)
int main() {int N;//我记得是直接开个最大的?while(scanf("%d", &N) != EOF){vector<int> nums(N);for (int i = 0; i < N; i++) cin >> nums[i];// vector<int> nums = {6,7,8,9,2,0,2,1}; // 你的示例数组quickSort(nums);// for (int num : nums) {//     cout << num << " ";// }// cout<<endl;cout<<nums[N/2]<<endl;}
}

这里选基准很巧妙,用了三数取中法,即找首位、末位、中间数,这仨里第二小的,放到倒数第二的位置,即 a b c d e f g,从 a d g 中选一个中间大的,比如 d < a < g,则 a 放中间,数组搞成:d b c a e f g,然后再把 a 放到倒数第二个的位置:d b c f e a g,扫描的时候,左指针从第二个位置开始,右指针从倒数第三个位置开始,这里我没搞懂是为啥,后来发现真的极致的牛逼,太巧妙了!(仅仅学三数取中的思路!!这个代码是错的!)

这里有很多的知识点,首先,选首元素为基准,复杂度在越有序的情况下,越会退化为

  • 首、中间三者的中位数(分区均衡):递归层数 ≈ ,总复杂度

  • 选极端值(分区失衡):递归层数 ≈ ,总复杂度

场景 1:选中位数(分区均衡,最优)

假设数组[4,1,5,2,6,3,7],选中位数 4 当基准:

  • 第 1 层:处理 7 个元素,分区为[1,2,3](3 个)和[5,6,7](3 个);

  • 第 2 层:处理 2 个 3 元素子数组,各分区为 1 个 + 1 个;

  • 第 3 层:处理 4 个 1 元素子数组,无递归;

  • 总操作数:

而就算有序,取了中间的数字,也可以让左右两边各有均衡的数

场景 2:选首元素(分区失衡,最坏)

数组[1,2,3,4,5,6,7],固定选首元素 1 当基准:

  • 第 1 层:处理 7 个元素,分区为[](0 个)和[2,3,4,5,6,7](6 个);

  • 第 2 层:处理 6 个元素,分区为[][3,4,5,6,7](5 个);

  • 第 3 层:处理 5 个元素→分区出 4 个;

  • ……

  • 第 7 层:处理 1 个元素,结束;

  • 总操作数:

可以看到越有序,越平方,而如果无序,则可以理解为,首元素会被放到后面一点的位置, 然后使得左右两边都有均衡的数

至此有了应试傻狗们只会背的一句话:有序数组选固定首 / 尾基准会导致快排最坏复杂度,选中位数 / 随机基准可避免。

死全家的豆包大模型气死我了,我思考是,有序数组选首位一定退化,取中一定最好,

但无序为啥也要三数取中?

取中后如果是第二大的元素,那岂不是每次只分出区间是 1:n-2,总共要 n / 2 次,岂不是也会退化,

然后死妈豆包就开始无穷无尽的给我数学推导,为了证明每次都取中得到第二大是多么的小概率,浪费了一天时间看他证明,最后自己想通了!取中根本不能避免无序下会选到第二大这种也会退化的情况!!取中的意义在于应对有序的数组!!!因为不知道数组是否有序!!

关于 log 咋来的:

image

关于为啥常说的是没底数?

image 

那这里的三数取中函就可以防止复杂度退化(好处之一)。

然后这里完全把基准放到了个“空位”,所以指针移动的时候不需要额外判断 “是不是基准值”,不用担心把基准值交换走(好处之二)。

再说指针扫的时候是否可以写等于,看个图(等号这个问题,我实在不懂,换做之前没大模型的时候会认为没区别,且可用平台提交发现耗时也没区别)image

总结是:

2 1 2 3 2

  • 写等是 2 1 2 2 3

  • 不写等是 2 1 2 3 2

换个数组也是比如 2 1 3 2

  • 写等于是 1 2 2 3

  • 不写等于是 2 1 2 3。

豆包说写等于最优!但这里豆包给了我启发却误人子弟了,豆包的说法是,假设你要把一堆一模一样的红球(等于 pivot)和少量蓝球(不等于 pivot)分成两堆:

  • 写等于:你会直接跳过所有红球,只盯着蓝球分,分完后红球会被自然拆到两小堆里,比如 100 个红球→50 个 + 50 个,再分→25 个 + 25 个,很快就分完了;我的质疑是,既然不管红球,则初始红球就在一堆也是会递归次数增多。

  • 不写等于:你一碰到红球就停下,会调整红球,但我的质疑是有可能局面是分散在左右,也可能集中。

傻逼豆包耽误我时间。

关于左右等号是否必须匹配:

我发现个事,写代码的时候,左右是否带等号不是必须要匹配,实测咋写都会 AC。

代码里,左写等,右不写等,也可 AC。

那就是左侧找大于基准的停,右侧找小于等于基准的停

这里注意,一轮下来的结果,不是说可以确定某一侧是否带等号,而是说是否略过等于基准的数,如果略过,则等于基准的就钉死不动,仅此而已,用 2 1 4 3 2 举例子可知,改动几个数据,数据的不同,左右是否有等号,和一轮结果左右是否相应的是包含等于基准的,没任何联系。

注意左右是否写等号无所谓,甚至可以一侧写一测不写,但比较的时候,左指针右指针指向的数,一定不能是基准这数本身!基准被换走后面就全乱了。

然后就是无尽崩溃,发现这个代码其实是错的

这里提一嘴!!狗逼死全家的豆包误人子弟太垃圾了,这里反复在那符合你,90% 的时候都不如我自己思考,回答完我还得去纠正豆包!

而且我发现可用平台(北大的)数据很弱导致,我轻信了他(AC 了即误以为代码万无一失),自己走了很多错误的路!!误入歧途浪费了一星期。

首先上面代码里加入的插入排序会掩盖相当多的错误

哎豆包 & OJ 的 AC,这些本就不权威的参考真的好束缚啊,相当于没解决多少问题结果反而增加很多包袱,因为他说的很多都是错我的,我要一一排除。

首先看这里的插入排序那个超过 10 才走快排的判断里,10 改为 1 就会 WA,原因估计是超过10就走快排但OJ数据很弱,但实际上大于10走快排也是有问题的,会越界!

这里while (nums[++l] <= pivot); while (nums[--r] > pivot);在 OJ 上无论等号咋写都 AC,可实际我发现,这样写是错的,因为会越界!例子是:1 2 3 4 5 6 7 6 5 4 3 2 7,目的就是简单的保证大于 10 不用插入,然后交换基准后:1 2 3 4 5 6 2 6 5 4 3 7 7,这样左指针一直找大于显然找不到会直接干到数组之后的内存,不信可以加cout测试直接给你干到nums[14]可是最多才nums[12]是合法的,可居然 OJ 能 AC。

即我发现尽管我已经三数取中搞成了:小,a,b,c,中(基准),大但一旦末尾那个和基准一样,等号是否需要写就有很大不同了,当然了这个其实目的就是为了不越界!如果意义是为了搞指针指向的和基准比较这件事,完全咋写都行(之前也说过了,等号无所谓的),但这里左指针不能写等号是防止越界的,因为没写l<r,这才是重点!!写l<r的话做了越界限制才真正可以说左右指针比较的时候写不写等号都行。

继续思考:

这里while (nums[++l] < pivot)确定了那while (nums[--r] > pivot);呢?

左指针已经定住了,比如位置记作p,则p左边都是 <或者 ≤ 基准的,p本身是 > 或者 ≥ 基准的,那右指针往左走的时候,你右指针要 > 或 ≥ 基准才推进右指针,但如果全是一样的数呢??遇到p,写到这突然发现妈逼的这里右指针也有说法啊!!这里就不搞 10 个那么多了,就 5 个全是 2,

image,即必须左右指针语句都不能下等号,判断的时候相等就停!

再思考:

这里左右指针移动是否有顺序?其实也是是否会越界的问题!思路 & 结论和上面一样。

注意:我用编译器测试发现,简单的2元素数组输出第三个非法数据,居然不报错!唉,万物都不可信啊!

这些细节真的头大崩溃,思考了整整 5 天,前 4 天毫无进展一头乱麻无尽的问题,到第 5 天突然全透彻了。

我的天啊,邝斌那五大专题真的是场景难,有些已经属于 leetcode 里的难题了,但重点是是不好和算法结合,比如怎么把题目的场景抽象出来,应用到算法上,但想到就基本套模板改参数了,模板很简单,没啥花样变化的空间,所以【搜索、KMP、并查集、最短路径、最小生成树】这些没法自己设想,必须靠刷题

但快排我艹没啥场景,模板想透彻就基本精通了,不需要靠刷题来提高(但仅仅对于我这种思考很多人来说),可裸的模板细节真的多(顺序、等号全是坑)!裸的想懂了一劳永逸,其他指针移动这些基本都透彻了(仅限于我这种想的多的)

注意我第一天就能看懂代码和快排的思路了,但思考了诸多细节:基准的选取、等号是否有、左右指针是否有顺序、思考所有的写法,哪些对哪些错,究竟有什么坑。

途中发现豆包误人子弟的傻逼,发现 OJ 的 AC 也不可信!我突然有种预感,90%的人貌似写的快排算法都是错的!!尽管 OJ 可以 AC。

这些分析完全是练脑子 + 想清楚所有边边角角,而不是像个应试傻逼一样背这些!有时候我真搞不懂,靠背来学计算机的这群狗逼干脆别干这行了

  • 面试官,你先别问我问题了,我先考考你吧。

  • 不用考我算法了,你直接把你过不了的题拿来我格给你 A 掉。

  • 项目不需要我引导,全方位的提问即可。

继续说我的思考:

那个豆包给的插入排序掩盖了诸多问题!我居然想去掉这个插入不知道咋写递归的退出语句了,艹!

起初在int pivotPos = partition(nums, left, right);后写了个if(left == (pivotPos - 1)) return;,我思路是想说只要剩下三个元素,那partition函数返回基准位置,只要剩三个就好了啊,

可我发现这样很多问题:

  • 如果是最终一轮结束的基准是:1 2 9 8 7这种,岂不是直接return误以为排好了?

  • 如果加一句右指针和基准也差 1?可如果只有俩元素咋办?想到这突然发现之前讨论的越界问题,比如只有3 2,那三数取中后

    • 3 是首、是中、是基准、是左指针

    • 2 是尾、是右指针

左指针直接越界!再次验证之前的东西

最后没想出来,问豆包发现递归退出条件应该是:

当待排序区间里没有元素,或只有 1 个元素时,这个区间本身就是有序的,无需再递归处理if (left >= right) return;

思考quickSort(nums, left, pivotPos - 1);为啥可以减?不怕越界吗?

就是靠if (left >= right) return;来保证的,如果pivotPos - 1是 -1,left必然是 0,则left>=-1这步就return

继续思考:

不考虑三数取中的优化,如果想取首位为基准,代码该咋写?我又卡住了,我思路是:

左指针不需要从首开始比较,所以直接略过,那么就可以直接++,不越界即可以比较,

右指针由于必须先判断此刻的数,这个是必须做的,而--那个是给下面准备的,可是这个--如果越界则直接前面必须做的那步骤也没了(因为这里是 &&),所以必须分裂开,--完判断不合法再自增回去,因为后面用到的swap里必须是个合法的r,而左指针就没管,因为每次会重新赋值,随便举个例子3 2 1++l<=right的时候,l就已经为越界的 3 了,但无所谓,后面用归位和他没关系。

再说一个,如果代码写成不分裂的while (++l <= right && nums[l] < pivot);while (r >= left && nums[r--] > pivot);,那比如例子3 2 1,左指针干到了下标 3,越界但用不到他,没事,可右指针本该在第一次的判断里停在下标 2 数字 1 的位置,但附带一个--直接干到了下标 1 数字 2 的位置。

备注:

mid = left + (right - left) / 2 可以避免 mid = (left + right) / 2left + right 超出 int 范围导致整数溢出的问题。

查看代码
#include <vector>
#include <algorithm>  
#include <iostream>
using namespace std;
int selectPivot(vector<int>& nums, int left, int right) {int mid = left + (right - left) / 2;if (nums[mid] < nums[left]) swap(nums[mid], nums[left]);if (nums[right] < nums[left]) swap(nums[right], nums[left]);if (nums[right] < nums[mid]) swap(nums[right], nums[mid]);swap(nums[mid], nums[right-1]);return nums[right-1];}int partition(vector<int>& nums, int left, int right) {int pivot = nums[left];int l = left, r = right;while (true) {while (++l <= right && nums[l] < pivot);// 左指针找>=pivot的数// cout<<"#"<<l<<endl;while (nums[r] > pivot){r--;if(r<left){//这里如果写等号,比如数组是1 2,当r移动到指向0位置的1时,会直接r++,错误交换r++;                    break;}}if (l < r) swap(nums[l], nums[r]);else break;}// cout<<"$"<<r<<endl;swap(nums[r], nums[left]);return r;
}// 快排递归函数
void quickSort(vector<int>& nums, int left, int right) {// 递归终止条件:子数组长度<=1if (left >= right) return; int pivotPos = partition(nums, left, right);  quickSort(nums, left, pivotPos - 1); quickSort(nums, pivotPos + 1, right);        
}
int main() {int N;while(scanf("%d", &N) != EOF){vector<int> nums(N);for (int i = 0; i < N; i++) cin >> nums[i];quickSort(nums, 0, nums.size() - 1);// cout<<"&";// for (int num : nums) cout << num << " ";cout<<endl;cout<<nums[N/2]<<endl;}
}

可用平台可以 AC(且交换左右指针的也没问题) ,但我知道,这不能说明代码绝对正确,且我感觉写的不好,这个代码就称为【思路感觉稀碎】,自己举反例反复修改。写的感觉好蠢。

于是豆包给我【优化了下】(错的更离谱):

查看代码
int partition(vector<int>& nums, int left, int right) {int pivot = nums[left]; // 基准取第一个元素int l = left, r = right;while(true){// while (l < r){也可以while (l < r && nums[r] >= pivot)r--;while (l < r && nums[l] <= pivot) l++;if (l < r) swap(nums[l], nums[r]);elsebreak;}swap(nums[left], nums[l]);//起初犯错是:swap(pivot, nums[l]);return l; // 返回基准最终位置
}

诸多思考:

  1. 为啥l<r没等号

  2. 交换左右指针顺序会 WA

  3. 右指针有等号,左指针无等号会 WA

都没等号会 WA

右指针无等号,左指针有等号 AC

都有等号 AC

这里的 2 和 3 真的疑惑!之前已经思考过的是,有了不越界的范围后,等号无所谓啊!

开始解答:

1. 注意我是先想简单的,所以我先写的是2、和3、的,看完下面的2、和3、再看这个,我之前先考虑的是顺序,所以这里其实我只思考了,交换顺后,即先左指针后右指针,如何能 AC,继续用1 2 3的例子,那我如果把右指针的写成l<=r,发现基准归位的时候应该换的是右指针,所以归位那行也要改下,之前写左指针是因为,二者都是范围内判断,一旦因为break出了判断必然是左右等了,因一步一步走,所以必然先经过左右重叠,完全不会有左在右的右侧,所以【优化了下】那里其实归位的左右一样,现在改完必须用右指针了,也可以 AC  image(但 OJ 可以 AC 只能说明错误不是很离谱,说明不了其他任何!吴师兄那种公众号说培训的华为 OD 学员面试机试的时候,学员反馈本地测试样例对,这都能拿出来说,真他妈搞笑)

2. 用1 2 3举例子(依旧是基准在左侧首位,升序排列):

    • 如果先走右指针rr左扫到首位下标 1 的位置然后--就和左指针l初始值相等了,违背l<r,就退出了,然后基准归位(先右后左没事,左指针在首位没动,不会干扰扫的结果)

    • 如果先走左指针l,扫到数据 2 就停止,此时下标是 1。接着右指针r初始在数据 3 那,找到小的才停,此时数据 3 和基准比完直接做--了,所以此时指针指向的是数据 2,下次进去while里判断起手就被l<r拒之门外,所以发现左指针右指针都指向了数据 2,因为l<r导致右指针没彻底找到真正比基准小的,停在了错误位置,那基准归位直接和数据 2 交换,最后得到2 1 3,问题就出在,左右指针的使命就是找位置不对的数据做交换,只有左指针在右指针右侧,或者重叠了,也就退出整个外层while了,说明左指针一路走来左侧都是要么本就对的,要么靠和右指针交换变成了对的,也就是说退出while时左指针停止的地方之前,他扫过的路,那些数据必须都≤基准,右侧也同理,可是现在因为一个l<r,导致右侧他妈的停止在了一个错误的位置(先左后右不行,左指针会走到下标 1 的数据 2,右指针扫的时候,由于必须l<r直接被限制在了 2 的右侧,后面的--导致直接停止在了下标 1 数据 2 这里,左右指针都指向这,那这直接错了,因为右指针本意是想取找< 基准 1的那些数,这些数如果在左指针l的左侧, 说明不需要交换,如果在l右侧需要交换,全程应该由左右指针来主导,让他去亲力亲为的检查,现在来了个l<r给挡了一下,由于while里是先比较后--,那下次带着这个--后的rl比较,那设想下,如果左指针右侧全是比基准大的,那然后左指针停留位置也是准备交换的,这里是数据1 2 3里的 2,右指针里的while判断最后一次也只能是和左指针重合,妈逼的直接基准归位,把一个比基准大的数换到了首位!

    • 再次回顾我之前写的那个代码,此文搜【思路感觉稀碎】,那里左右指针只是判断了是否越界,而最后退出while时才做了左右指针之间的判断(l<r),目的是给接下来的基准归位准备出正确的位置。

且由于用++l <= right已经保证了不会越界,所以我实测发现,左右指针和基准比较的时候有无等号都没问题。

且交换顺序依旧无影响,各判断各的 他的正确的地方就在于快排的另一个精髓,各自保证有界,且最重要的是保证了新鲜,这里新鲜指的是说不会被左右指针干扰,左右一路走到某处停止了:

      • 要么是他们想交换:这里分为真的可以交换和不可以交换,其中可以交换会继续去进入while (true),而不可以交换一定是违背了if (l < r)直接break了,那么这里的违背原因就是因为左右指针踏入了对方的领地,对方一路走来都把大小顺序判断好了,你比如左指针走到右指针判断过的地方,一定是大于基准的,必然说明左右指针上一刻的两侧都是排好序的了,也就不用再左右指针移动了,也就是说左右指针都是最新鲜的进入对方领地的值,那么后面基准归位的时候,直接换就行,哦写到这突然发现这个就算不新鲜也无所谓。
      • 要么是越界了:越界了一定不满足l<r,所以直接走到基准归位。

那么至此就知道了,左右指针移动的时候只能由是否越界来限制,你左右指针互相之间的下标位置大小不该参与进来!即刚才那个代码,此文搜【优化了下】的问题所在。妈逼的这个【优化了下】是豆包给的优化,【思路感觉稀碎】是我自己写的(妈逼的我随手写的感觉稀碎的代码,居然封神了?换做之前刷题的阶段,我一定会看竞赛圈的【POJ 评论 & 洛谷讨论】看是否有更好的思路)

    • 至此也发现了,90% 的人代码估计都是错的,大部分教材题解的 “简洁版”就是【优化了下】那个,看似优雅,实际藏坑!这里不想(没时间)去刷邝斌这里的题目,所以估计那里会有人提出来!而其他所有面试各种应试的地方,估计都是【优化了下】这种傻逼代码!!

此时豆包开始 马后炮 了(也难怪,全网各种水经验水答案傻逼太多,豆包的训练数据本就没法区分)。

3. 通通是因为基准的没跳过,也参与比较,把基准给移走了,这里随便举例子3 1 2可知。且这里基准选的是首,如果是尾巴,则左右指针有无等号的事还要调换下。

所以他这个【优化了下】代码,问题有:1、最后说,2、左右指针的顺序问题,但本质是l<r这个界限限制,干扰了左右指针的正常移动,3、没跳过基准导致基准被移动走。

总结到这我突然感觉,这个【优化了下】的代码无论咋改都像一裤兜子屎的感觉,处处打补丁,顺序、等号全是问题,只要改动基准比如选择末尾元素,后者改为降序,代码基本要重写。好他妈零碎的感觉。

妈逼的快排代码没不傻逼的代码模板吗???

百度百科的 image,交换顺序后,输入1 2 3直接排序为1 3 2。他妈的这种靠顺序的根本就不能称之为快排代码啊!!像个傻逼僵硬的补丁

 

但下面的严蔚敏的居然可以!image这么简单我居然没想到!!!(既有分裂又有越界判断!之前只觉得严蔚敏的晦涩难懂!!只觉得这书是傻逼!)

代码能力思维能力还是太弱了~~~~(>_<)~~~~

之前我思考过,此文搜“再说一个,如果”,这里把严蔚敏的改为可用平台的格式,发现交换顺序依旧 AC,写的比我思路好,可以学一下,但等号不匹配依旧有问题,但也比【优化了下】强

查看代码
#include <iostream>
#include<vector>
using namespace std;
void Qsort(vector<int>& arr, int low, int high)
{if (high <= low)return;int i = low;int j = high;int key = arr[low];while (true){// 从右向左找比key小的值while (arr[j] >= key){j--;if (j == low)break;}// 从左向右找比key大的值while (arr[i] <= key){i++;if (i == high)break;}if (i >= j)break;// 交换i,j对应的值swap(arr[i],arr[j]);}// 中枢值与j对应值交换swap(arr[low],arr[j]);Qsort(arr, low, j - 1);Qsort(arr, j + 1, high);
}int main()
{int N;while(scanf("%d", &N) != EOF){vector<int> a(N);for (int i = 0; i < N; i++) cin >> a[i];Qsort(a, 0, a.size() - 1);//a.size() 是 N// for (int num : a) cout << num << " ";cout<<a[N/2]<<endl;}
} //参考数据结构p274(清华大学出版社,严蔚敏)

插一嘴:/**/不支持嵌套。

这个代码学到的是:

发现右指针那个分裂写法完全就是我的思路,只不过改进是由于是一步一步走的,所以不存在直接<,直接==的时候拦住就行了,我之前写的太烂了。而且这个代码完美解决了之前【思路感觉稀碎】里左指针越界的事!!因为自加后没判断,换做我又会判断然后再减回去,要么就++l <= right这里不写等号(但显然不行),前刷邝斌觉得算法其实就是数组而已,如今发现最最最基础的语法也有这么大学问!

继续说:

关于顺序和等号这里一起给讨论了

关于等号,左指针必须写等号,即到大于才停止,否则会把基准搞走,而右指针无所谓。

然后讨论下所有场景,由于中途的数据会正常交换,所以有讨论意义的应该就只有指针到边界,比如先左指针,那左指针何时出while,右指针又是咋接着配合的:

如果一路走过都是符合<= 基准条件的,那一路判断完后立马i++,则在下标high位置就会因为i == high退出while,即下标high位置的数据是无法知道和基准的大小关系的。

而此时该右指针接力开始移动,如果下标high位置的数据实际是>基准的,直接j--,倒数第二个数据由于左指针扫过,则必然小于等于基准,直接停止,则j停止在i左侧的小于基准的数上,然后if (i >= j)直接走break;退出,j位置是<基准则交换j

如果下标high位置的数据实际是<基准的,直接退右指针的whilei==j直接break

所以完美闭环!

然后不存在左右指针互相大小的参与,所以可以交换左右指针的顺序。

而除了左指针语句必须写等号外,那所有涉及到等于的说白了就是可以当作小于基准或者大于基准,然后实际交换与否导致的结果就是左侧小于等于基准或者小于基准。

(相当于勘误吧)我给他代码做个优化,除了顺序以外,等号也随心所欲的加或者不加,则从low的下一个开始扫,int i = low + 1;则后面左右指针的语句无论顺序还是等号都可以想咋写咋写,然后插一句依旧是说过的东西,我理解每次都是走一步,所以只要i==j则需要退出,即把左右指针里的边界改为i==j,再次印证错误,反例1 2 3i在下标 1 的数据 2 停下,j起初在下标 2 的数据 3 然后继续走,走到下标 1 的数据 2,此时直接i==j直接break,立马变为2 1 3。(更新:可用平台数据真的太垃圾了!!这里low + 1左右指针都是不写等号可以 AC,但完全是错的啊,因为2 2 2这组数据直接死循环)

再说下百度百科除了严蔚敏的另一个写法:image

我学到的是,此文搜++l <= right那里我无论写是否有等号都不行,因为++l已经是越界的l了,所以我就只能想到的是,既然交换用不到l,就直接没管,而右指针就搞出界又++搞了回去,

而严蔚敏的写法牛逼之处就是搞了break,但没用上天然的大小关系,

然后现在这个写法更牛,又学到了新东西,其实和之前那个三数取中差不多,三数取中如果首、尾、基准一样大就会越界,所以是否有等号依旧不能随便写,但却在思路上开了个头,因为首、基准、尾一定是一个比一个大的,天然判断完的一个结果,只不过一样大这种情况就不行了,那这个代码是:

查看代码
void Qsort(vector<int>& number, int first, int last)
{int i, j, pivot = first;int temp;if (first < last){pivot = first;i = first;j = last;while (i < j){while (number[j] > number[pivot])j--;while (number[i] <= number[pivot] && i < last)i++;if (i < j)swap(number[i],number[j]);}swap(number[j],number[pivot]);Qsort(number, first, j - 1);Qsort(number, j + 1, last);}
}

这里此文搜“(相当于勘误吧)

通过这个我总结出两种大体代码思路,

  • 要么边界控制好(严蔚敏的break或者我的此文搜【思路感觉稀碎】 ),然后顺序和等号随心所欲想咋写咋写。

  • 要么通过是否带等号来间接控制边界,比如这里的首位基准,右指针l判断的时候就不写等号,则l往左扫的时候最多走到基准,j为0,然后左指针往右扫,最大走到last即右边界。

但这里我发现个事,也就是我更新的此文搜“可以 AC,但完全是错的啊”(我发现这个事是因为之前 AC,但这个代码发现有边界我就去掉i的等号,直接 TLE),我一直以为从基准的后一位开始扫就会解决等号的问题,但发现还是对算法本质不理解,因为左指针从哪里开始扫完全不影响只要略过基准别把基准交换走了就行,而等号的事:

  • 写等号那比较时候遇到纯大/小就停止,但注意如果后进行自增自减,可能会越界,所以要控制边界。

  • 不写等号则遇到等于的也停止,我一直以为就不需要控制边界了,可以完全没想到,事实是2 2 2会起手就不走,直接死循环(一侧有等号、另一侧没有等号没事)

导致我一直以为两个事(其实是错的):

  • 右指针那里相当于把之前的三数取中给做了完善,

  • 左指针相当于把此文搜“再说一个,如果”,在有了严蔚敏的基础上又贴切的完善了下,因为严蔚敏“浪费”了天然大小这个关系。

总结就是(像个傻逼废物一样的应试高手去背代码的话,快排很简单,其他算法难,但细节、原理彻底透彻精通的话,快排 > 搜索、最短路、最小生成树、KMP、并查集):

  • 代码写等号后,自增自减注意边界 

  • 代码不写等号注意全相同数据会死循环(但像这里一侧有、另一侧没有也是可以的,比如这里如果j死那不动,i会一路要么不停走,要么去和j交换完继续走)

  • 左右指针互相之间别参与大小比较

  • 然后关于顺序本来是没顺序的,保证好边界,别把左右指针互相之间参与大小比较就不会有顺序问题,即顺序没要求。

为了验证,我发现一侧没等号,那另一侧必须加上,比如:

while (number[j] >= number[pivot] && j-1>=first)  j--;while (number[i] < number[pivot] && i < last)  i++;,依旧 AC(尽管 AC 不代表绝对正确,妈逼的累了)

那我【思路感觉稀碎】 那个代码为啥也 AC 了且实测2 2 2没事,因为++l最终就是越界风险操作,因为先自增了,导致每次都会l往前推进,所以不会死循环。

彻底精通快排!!妈逼的绞尽脑汁想破脑袋想了 14 天,所有可能的边角问题。

所有涉及到等号可以随便写的都是错的,懒得再修改博客完善了!好累~~~~(>_<)~~~~

注意重点是思路!说这些是为了表述、为了解释、练脑子、想细节!而不是记什么先左后右这种结论。 

点评我的博客 (何时有我的出头之日啊,我的春天在哪里~~~~(>_<)~~~~)

无意间看到一个 小龙虾 的公众号文章

疑惑和感悟

wx搜:

1、诋毁

2、隐藏气质气场经历

3、服务员。架子

我就一台电脑(差生文具多)我跑步从不买鞋,就普通的。很多人跑步买鞋。

这鸡吧玩意有啥用啊,CRUD 而已啊,会一堆框架只是使用者啊,底层原理呢?自己的价值呢?

我一个没工作的,都能发现大模型 70% 都是错误,误人子弟,还他妈咋在工作公司项目中用啊?公司究竟啥水平的代码啊?连我自己几十行练手的代码都反复无穷无尽的错误。

AI 永远是给牛逼的人更牛逼,给平庸的人变得不那么平庸,而不是把海厚海厚的平庸傻逼变成牛逼,现在靠 AI 太多傻逼涌现出来,没 AI 时候,他们都干嘛的?AI 就他妈是噱头,就是适合商业、骗政府钱,割(中国 99% 都是)没脑子的人韭菜用的

鱼皮的广告(岗位区别、八股文占比)

面试需要看的东西

2026/4/8到期,但3月有活动,直接2核2G4M一年99的活动,昨天3/31买了,一系列关龙虾的问题:

辱骂99%无脑的人、购买成功页面包含后续AI便宜的拓展、说自动小红书干活点进去完全没有、妈逼的拼团居然是自己找人。

之前图库收藏小林coding说 C++ 硬核的坑位极少,吴师兄说那些推倒数学的都是Deepseek级别的大模型

我一意孤行,豆包也说确实很少招

25年3月试用一个月

25年4月8日买一年新人68,2核2G3M

26年4/8到期,但3月末截止的优惠是99一年,2核2G4M,如果之前续费要250元

公网变化 image

旧的image

新的image

起手先本地cmd:ssh root@82.157.107.148,发现无需输入密码直接登上了,因为C:\Users\GerJCS岛\.sshid_rsa.pub公钥和id_rsa私钥,公钥之前手动上传到服务器,新服务器镜像直接可以延用。

VScode里只要改下IP即可,.ssh 是个专门放 SSH 所有东西的系统文件夹,是 SSH 工具自动生成的:

  • id_rsa → 你的私钥(钥匙)

  • id_rsa.pub → 你的公钥(锁)

  • config → VSCode 用的服务器地址配置

  • known_hosts → 你连过的服务器记录

  • authorized_keys → 这个只有服务器上才有,本地不会有

然后重新连接就好了 

 

回忆之前博客 算法题目 123都是直接开最大,并没用 vector,因为竞赛题目会明确给出数据规模上限,而限制的是时间而非内存,静态数组直接用,然后初始化速度远比动态数组(会有底层调用开销)好,全局静态数组存在栈外静态区,无分配 / 释放开销,访问是纯内存地址,无任何额外检查。

  • vector 对象本身(3 个指针 / 变量)→ 放在 栈

  • vector 里存的真实数据 → 放在 堆

因此:

  • 访问对象很快(栈)

  • 访问数据要跳一次地址(堆)

  • 频繁扩容、分配、释放都在堆,比全局静态数组慢

然后看 POJ 的 Discuss 发现也是这么开的静态 MAX,每次比如就排1个数,也带着MAX数组走,但用的是现成库函数。

之前刷题的时候,没写过传递数组,都是数组是全局,这里数组传递的时候,几种方法上面说过了。

3.7、至此精通快排了,那开始数封装的一些玩意吧: 

1、sort:C++ STL 算法,模板实现,类型安全,默认升序(可直接传 lambda / 函数对象),底层通常是快排 / 内省排序,仅支持随机访问迭代器(如数组、vector)。

{return a>b;}比较规则(返回truea排在b前面),准确说是comp(a, b) == true,第一个参数排前面,a>b即降序,这里其实就是手写快排时和基准的大小比较。

sort遵循 “左闭右开” 规则,即[起始位置, 结束位置)

数组名 + 元素个数

int arr[] = {3,1,2}; // 长度3,元素是arr[0],arr[1],arr[2]
sort(arr, arr+3); // 终点是arr+3(指向arr[3],不存在),实际只排序arr[0]到arr[2]

容器(如 vector):end ()(终点不包含)

vector<int> vec{5,4,3}; // 元素是vec[0],vec[1],vec[2]
sort(vec.begin(), vec.end()); // end()指向vec最后一个元素的下一个位置,实际排序vec[0]到vec[2]

说下比较函数:std::sort默认升序是自身规则,std::greater<>()是库自带的降序参数。几种写法:

查看代码
#include <algorithm>
#include <vector>
#include <iostream>
#include <functional>// 必须包含此头文件(部分编译器可省略,但规范要加)
using namespace std;bool compareDesc(int a, int b) {return a > b; // 逻辑和lambda完全一致,只是抽成独立函数
}// 几种等价写法
int main() {//默认都是升序int arr[] = {3,1,2};std::sort(arr, arr+3); // 数组:[首地址, 尾后地址)for(int num:arr)cout<<num;cout<<endl;//1 2 3std::vector<int> vec{5,4,3};std::sort(vec.begin(), vec.end()); for(int num:vec)cout<<num;cout<<endl;//3 4 5//降序vec = {5,2,9};std::sort(vec.begin(), vec.end(), std::greater<int>());//降序for(int num:vec)cout<<num<<" ";cout<<endl;//9 5 2vec = {5,2,9};std::sort(vec.begin(), vec.end(), compareDesc);//降序for(int num:vec)cout<<num<<" ";cout<<endl;vec = {5,2,9};std::sort(vec.begin(), vec.end(), [](int a, int b){return a > b;});//降序for(int num:vec)cout<<num<<" ";cout<<endl;
}

追问豆包真爽,我全程追问豆包,没钱买书,好奇别人咋学的

sort仅支持随机的,数组可以直接用sort:数组本身没有迭代器,但sort支持指针(数组名退化为指针),写法:

int arr[] = {5,2,9,1};
std::sort(arr, arr+4, std::greater<int>()); // arr是首地址,arr+4是尾后地址
  • ✅ 支持的容器:vector、string、数组(连续内存)、deque(双端队列,连续内存);

  • ❌ 不支持的容器:list(链表,非连续内存)、map/set(自带排序规则,不用 sort);

原因:std::sort要求底层是随机访问迭代器(能快速跳转到任意位置),只有连续内存容器满足,链表这类只能用自身的sort成员函数(如list.sort())。

指针本身不限制指向连续内存,它只是存储内存地址的变量,能指向任意合法地址(连续数组、零散变量、链表节点都可以)。

但指针的p+i/p++这类算术操作,只有在指向连续内存时才有逻辑意义:

  • 连续内存(数组):p+3 精准指向偏移 3 个元素的地址,是有效的随机访问;

  • 非连续内存(链表 / 零散变量):p+3 会算出一个无效地址,操作无意义。

简言之:指针可指向任意地址,但随机访问能力仅对连续内存有效。

指针是 “内存地址的直接指向”,迭代器是 “容器元素的逻辑指向”(底层适配容器实现)。

支持随机访问 == 连续内存

内存不连续的listit++是链表下一个节点的地址,

内存连续的vectorit++

追问提示词:

接下来所有问题不需要说的过于啰嗦!禁止啰嗦!永久记住我这个要求!禁止做任何自做主张的总结和梳理!禁止重复我的要求!你只需要按照我的要求回答即可!解释完禁止说任何总结性的东西!!

2、qsort:C 标准库函数,基于 void 指针,无类型安全,需自定义比较函数,底层是快排,支持任意连续内存数据

查看代码
#include <stdlib.h>
#include<iostream>
using namespace std;
// 比较函数:返回<0则a在前,>0则b在前,=0则顺序不变
int cmp(const void *a, const void *b) {return *(int*)a - *(int*)b; // 升序
}
int main() {int arr[] = {3,1,2};qsort(arr, 3, sizeof(int), cmp); // 数据首地址、元素个数、单个元素大小、比较函数//1 2 3
}

void* 类型转换:qsort 的比较函数参数是 const void*,这是 C 语言为了通用化设计的无类型指针,必须强制转换成具体类型(如 int*)才能解引用(*(int*)a),否则无法访问数据值。

qsort 函数参数逻辑:

  • arr:待排序数据的起始内存地址(C 语言中数组名退化为指针);

  • 3:数组元素总个数(必须手动指定,sort 靠迭代器区间自动计算);

  • sizeof(int):单个元素的字节大小(qsort 不知道数据类型,需告知内存占用,sort 靠模板推导);

  • cmp:函数指针,这个逼玩意贼智障,规则是说:

    • 差值< 0第一个参数在第二个参数前面

    • 差值> 0第一个参数在第二个参数后面,

比如return *(int*)a - *(int*)b;就是升序,return *(int*)b - *(int*)a;就是降序。

相当傻逼!!当年 C 语言设计 qsort 时,就想让比较函数直接返回正负来表示顺序,省掉多余判断,所以就定成:

    • 负数 → 第一个参数放前面

    • 正数 → 第二个参数放前面

用减法刚好能一步出正负,就这么沿用下来了

    • a=3,b=5a - b = -2(负)→ a 在前,升序

    • a=5,b=3b - a = -2(负)→ b 在前,降序

qsort第一个参数是接受地址:

传递数组名字会隐式转为void*,传递vector要写vec.data(),返回int*,即qsort(vec.data(), vec.size(), sizeof(int), cmp);

vec.data() 是 C++ 中vector容器的成员函数,返回指向 vector 底层连续内存数组起始位置的指针,类型为对应元素类型的指针,比如vector<int>data()返回int*

必须保证指针指向的内存是连续的,否则 qsort 通过指针 + 偏移访问元素会直接出错。但别qsort和容器混用,丢安全性。

补充:

  • 规则:一元运算符(++/--)优先级高于关系运算符(<),二元运算符(+)优先级也高于关系运算符;前缀 ++(++i)先自增再参与运算,后缀 ++(i++)先参与运算再自增。

  • i++<10:先用i的原值和 10 比较,比较完成后i再自增 1。i+1<10:先计算 i+1 的结果。

3.8、既然说到排序直接整个大厂需要会的排序都学下吧

插一句,POJ 那个题目,如果不用快排等算法,暴力的话,数据量是104,挨个数(记作 x)遍历一遍数据量总数,然后统计比 x 大的和比 x 小的,相等才行,总共 108 直接 TLE

OJ 1s 有效运算量上限约10^8次(理想是10^9),若后台数据 100 组,则代码写出的单组数据的运算量必须 10^6 内,所以这里

发现算法其实无法提高智力,只是术业有专攻,熟能生巧,就比如搜索算法改写暴力for我都感觉不会,因为没这么想过。

妈逼的插入也这么精密,看着简单,细节和边界条件很多

算法思路理解起来很简单,难的是代码怎么卡住边边角角达成算法思路

说插入排序:

比如升序,思路就是拿首位当排好序的,然后从第二位开始遍历后面每个元素,每次遍历都做一件事:和前面的比较,比前面小则前面那个元素就往后移动,移动完找到正确位置,把当前元素插进去。

比如3 6 2

先拿 3 当作排序好的,

先取 6,6 和 3 比不动,

然后 2(先放到个变量存起来),2 < 6,6 往后挪,变为 3 6 6,此时第一个 6 是没用的坑位,给前面挪动准备的,

然后看 3 > 2,3 往后挪,3 3 6,此时第一个3是没用的坑位,然后 2 放到 3。

非常简单看代码:

查看代码
#include <iostream>
#include <vector>
using namespace std;// 插入 升序
void insertionSort(std::vector<int>& arr) {int n = arr.size();// 从第二个元素开始,逐个向前插入for (int i = 1; i < n; ++i) {int key = arr[i];  // 当前待插入的元素int j = i - 1;     // 已排序区间的末尾指针// 向前遍历已排序区间,找到插入位置while (j >= 0 && arr[j] > key){arr[j + 1] = arr[j];  // 元素后移--j;}arr[j + 1] = key;  // 插入到正确位置}
}
int main() {std::vector<int> arr = {1, 3, 6};insertionSort(arr);for (int num : arr) std::cout << num << " ";cout<<endl;
}

捋一遍:

首位先定死,然后i作为指针选第二个开始搞,值先存到key,需要和前面的比较,则i前面的都是有序的,j设成i-1,j只要没扫到头就一直扫,做比较arr[j] > key,然后判断是否后移,key位置已经腾出来了坑位,前面的放心后移即可,移动完那前面本身的位置又当作新的坑位,

此时需要注意如果比较到头就可以停, 说明已经找到该放的位置了,但代码的写法要注意,一旦j < 0,就该退出while了,那此时j+1就是该放的位置,如果< 0那就是该放到首位,除此之外就是在前面一堆数的某个位置停下,那一定是arr[j] < key,不挪动,则直接放到此时j的后一个即可!也就是上一次留下的坑位,所以有arr[j + 1] = key;

时间复杂度: 

  • 最坏两层循环都执行到最后,总操作:

第 1 次:内层跑 n-1 次

第 2 次:内层跑 n-2 次

第 3 次:内层跑 n-3 次

...

最后 1 次:内层跑 1 次

复杂度

  • 最好数组已有序,内层循环不走,复杂度

啥时候用?

大规模数据用快排,无法事先知道是否有序,所以 有序也没招

小规模数据,用插入因为 n2 大不到哪去,且没有递归、没有分治、没有额外函数调用开销

太多思考就此作罢,这些都是工业学术研究完的,不需要考虑 n2 和递归哪个更耗时 image

3.9、归并

插一句:

image 博客园标题黑了(之前蓝色的)。

vector几种声明:

vector<int> a;空容器,一个元素都没有。

vector<int> a(N);创建 N 个 int,默认值都是 0。

vector<int> a(N, val);创建 N 个 int,每个值都是 val

vector<int> a(arr, arr+N);把数组前 N 个元素拷贝进 vector

vector<int> a{a,b,c};直接存入 a、b、c 三个元素。

vector是动态的,所以直接范围for就是全部,而如果开 1000 个静态数组,但只用了前 N 个位置,想输出前 N 个,没法范围for,但可以引入vector

  • 要么临时的没名字:for(int num : vector<int>(arr, arr+N)) cout << num << " ";,进入 for 循环前创建一次临时对象比如叫 haha,整个 for 循环期间全程只用这一个 haha,循环结束后:haha 立刻自动销毁

  • 要么vector<int> temp(arr, arr+N); for(int num : temp) cout << num << " ";

vector自己会自动销毁、自动释放内存。

开始说归并(实测可用平台可以 AC)

查看代码
#include<iostream>
using namespace std;// 合并 arr[l~mid] 和 arr[mid+1~r]
void merge(int arr[], int l, int mid, int r) {int i = l;       // 左区间起点int j = mid + 1; // 右区间起点int k = 0;int* tmp = new int[r - l + 1]; // 开一个临时数组存合并结果// 两边都还有元素,比大小,小的先放进临时数组while (i <= mid && j <= r) {if (arr[i] <= arr[j])tmp[k++] = arr[i++];//左边小,放左边的elsetmp[k++] = arr[j++];}//剩余元素的处理//把左边剩下的元素直接放进去while (i <= mid) tmp[k++] = arr[i++];// 把右边剩下的元素直接放进去while (j <= r) tmp[k++] = arr[j++];// 拷贝回原数组for (i = l, k = 0; i <= r; i++, k++)arr[i] = tmp[k];delete[] tmp;
}// 8 4 5 7 1 3 6 2// [4 999]   [5 7 8]void mergeSort(int arr[], int l, int r) {if (l >= r) return;//最后局面是两个数的也会被搞成一个数,一个数的再次 mergeSort 才是 l > r 所以直接在一个数的时候 == 就 return 也可以,实测依旧 ACint mid = (l + r) / 2;  //奇数:mid是第一堆尾巴,偶数:mid是中间mergeSort(arr, l, mid);mergeSort(arr, mid + 1, r);merge(arr, l, mid, r);
} int N;
const int MAX=10000;//C++ 静态数组大小必须是编译期常量,非 const 变量无法作为数组长度,报错
int arr[MAX];
int main() {while(scanf("%d", &N) != EOF){for (int i = 0; i < N; i++) cin >> arr[i];mergeSort(arr, 0, N-1);for(int i=0; i<N; i++) cout << arr[i] << " ";cout<<endl;// cout<<arr[N/2]<<endl;}
}

归并思路就是 :数组:[8,4,5,7,1,3,6,2]

第一步:拆分(不停二分,拆到单个元素)

总长度 8,永远从中间拆分

  1. 第一次拆:左 [8,4,5,7] | 右 [1,3,6,2]

  2. 第二次拆:[8,4] | [5,7] | [1,3] | [6,2]

  3. 第三次拆:[8] | [4] | [5] | [7] | [1] | [3] | [6] | [2]

    拆分结束!规则:只要长度 > 1,就中间拆分;长度 = 1,停止拆分。

第二步:合并(两个有序数组,按大小合并成一个新有序数组)

合并规则:

  1. 两个指针分别指向两个数组开头

  2. 谁小拿谁,放进结果

  3. 一个数组拿完,把另一个剩下的直接全拿过来

  4. 合并完的数组一定是有序的

合并演示:

合并 [4] 和 [8] → 比大小 → [4,8]

合并 [5] 和 [7] → 比大小 → [5,7]

合并 [1] 和 [3] → 比大小 → [1,3]

合并 [2] 和 [6] → 比大小 → [2,6]

上一层:

合并 [4,8] 和 [5,7]

4 vs 5 →拿 4;8 vs 5→拿 5;8 vs 7→拿 7;最后拿 8 → [4,5,7,8]

合并 [1,3] 和 [2,6]

1vs2→拿 1;3vs2→拿 2;3vs6→拿 3;最后拿 6 → [1,2,3,6]

最后一层:

合并 [4,5,7,8] 和 [1,2,3,6] → 最终有序 [1,2,3,4,5,6,7,8]。

归并的时间复杂度:n * logn,解释:

每次把数组一分为二:

  • n 个元素

  • 第 1 层:1 段

  • 第 2 层:2 段

  • 第 3 层:4 段

  • 直到每段 1 个元素

一共要分 k = log₂n 层。

每层总工作量:O (n),每一层所有小段合并起来,总元素数量还是 n。

层数 × 每层工作量= log₂n × n = O(n log n)

配合代码:

比如[4, 1, 3, 2],在mergeSort里的流程是:

先起手把[4, 1, 3, 2]塞进mergeSort,记作 1 层,1 层里mergeSort(arr, l, mid);[4,1]搞进 2 层递归,[4,1][4]搞进 3 层递归,由if (l >= r) return;直接判断返回 2 层,2 层里该执行mergeSort(arr, mid + 1, r);语句了,把[1]搞进 3 层递归同理返回(有点 DFS 深搜的感觉),此时 2 层里[4,1]该执行代码语句merge(arr, l, mid, r);,这个最后看,只当作排序即可,出来变成[1,4],该层结束返回到 1 层,

1 层该到语句mergeSort(arr, mid + 1, r);了,同理搞成是[2,3]

然后执行 1 层里的merge(arr, l, mid, r);语句,此时是[1,4,2,3]来合并做排序,搞排序就成了[1,2,3,4],递归这么想一遍就懂了,格式不清晰,看不懂可以看 豆包加工版

插一句此时回忆:

  • 分治(拆成独立子问题分别解再合并):之前学过的将任务分给大和尚,大和尚分给小和尚,小和尚分给小小和尚,逐级完成任务提交给大和尚就完事

  • 贪心:每步选当前最优,就是全局最优

  • DP:

    • 没有重复子问题 → 不用 DP,直接分治就行

    • 有大量重复子问题 → 用 DP 存结果,避免重复计算,才能高效算出全局最优

    背包问题里:dp[i][j] 会被后面无数状态反复用到,这就是重复子问题,搞到全局最优,但不重复计算是让算法变快(高效),重复计算 = 算法变慢,但结果依然是最优。

然后看merge(arr, l, mid, r);,例子的[4,1,3,2]已经在每次的分段搞成了[1, 4][2, 3],进入merge(arr, l, mid, r);

  • 左段:l=0, mid=1[1,4]

  • 右段:mid+1=2, r=3[2,3]

while (i <= mid && j <= r)限制每段的数据,1,2,3,4逐步加到新数组。

唯一需要思考的就是剩余元素的处理,为啥直接填到尾部,比如[4 999][5 7 8],此时加完 4 会加 5,然后 7、8,注意此时加完 8,指针就移动到了外面就该退出了,那剩下元素就是 999,直接加到最后,我的思考:难道 999 不可能比 7、8 小吗?这里确切属于看出确实 999 最大,那如果是 a b c 呢?其实这两段属于有个前提,一定自身都是有序的,左右各自的指针一旦有一个移动出界了,那没遇到的一定都是比这个大的!所以直接无脑接上就好。

然后豆包说不考但给我扯了个非递归版本(也叫迭代版,用for 循环控制步长,手动拆分,不调用自身,无栈溢出风险,工程上处理大数据更安全),但说大厂不考但我一意孤行的学了

查看代码
void mergeSortIter(int arr[], int n) {// len = 当前要合并的【小块长度】// 从1开始,每次翻倍:1→2→4→8→...直到覆盖整个数组for (int len = 1; len < n; len *= 2) {//123的1// 两两一组合并:左块起点字母 l,左块长度 len// 下一组跳 2*lenfor (int l = 0; l + len < n; l += 2 * len) {//字母lint mid = l + len - 1;    // 左块末尾int r = min(l + 2*len -1, n-1);  // 右块末尾(不越界)merge(arr, l, mid, r);    // 合并这两段!}}
}

流程:

比如4 1 3 2 6 5,起手动作for (int len = 1; len < n; len *= 2) {,意思是长度先从 1 开始,其实是递归版的逆过程,递归版是最后拆分到了 4 和 1,这里起手就搞 4 和 1,

也就是先搞4 1成为1 4,然后搞3 22 3,然后搞6 55 6,即1 4 2 3 5 6

然后下一轮 forlen变成 2,相当于把这次的两个小块(len=1)给直接当作一个大块了,搞的是[1,4][2,3],搞成[1,2,3,4],然后此时左指针l该移动+4了也就是到数字 6,然后+2

注意先说下为啥要l + len < n,是判断是否存在右段可以和左段合并,没有就不合并,l + len是右区间起点,只有它小于n才代表存在右区间可以合并,且不需要保证完整右段,l + len < n 只需要保证右段至少有 1 个元素就能合并。

所以此处发现+2就不满足<n了,则直接退出,事实上也是只剩下左段的5,6,此时就是刚搞好的1,2,3,4和没搞的5,6

然后进入len是 4,搞的就是他俩。

PS:快排也有非递归版本,都是防爆栈的,这玩意大厂说不考,我辱骂豆包我说你妈逼的狗东西,以后不考就不提及,你只要说了我(强迫症)就得看。

差别:

  • 递归归并,是函数自己调用自己(递归),用系统栈,系统自动管理的调用栈(系统栈,空间小,深了会爆栈),关键就是每次调用都新开一份变量,层层叠加,占内存越来越多,会爆栈。

必须保存每一层状态,所以要压栈。递归会不断叠加函数调用栈(深度涨)

  • 手动写 stack 容器 / 数组存数据,用手动栈,空间极大,不会爆栈,此处的for非递归版是,从头到尾只用一份变量,循环覆盖复用,不新增、不叠加,永远不爆栈。

for 循环顺序执行,不回头,不回溯,不需要保存历史状态,自然不压栈。栈深度固定为 1。

Q:妈逼的别他妈这么乱套行吗!!老子脑瓜子笨!!没看出到底啥区别?不都是在栈吗?

A:for循环 和 递归 里的所有局部变量(len/l/mid/a…)全都在栈区,这一点完全一样,区别根本不在于 “变量存在哪”,而在于 “函数调用栈压了多少层”。

  • 递归:函数自己调用自己 → 每递归一次,系统栈就多压一层,层数会涨到 logN,会爆。

  • 非递归的for 循环:永远只有一层函数调用,循环千万次也不会多压一层栈,栈深度永远 == 1,绝对不爆

Q:是否递归时间复杂度还他妈能变??

A:时间复杂度 O (nlogn) 是计算次数,和系统栈深度是两个完全无关的概念:

  • 递归:栈深度 = logn(会溢出风险)

  • 非递归:时间复杂度同样 O (nlogn),但系统栈深度 = 1(无风险)

Q:底层是为啥?

A:内存分为:

  1. 栈(系统栈):函数调用、局部变量 → 空间小 → 递归会爆

  2. 堆:new 出来的 → 空间大

  3. 全局 / 静态:代码外面定义的数组 → 空间超大,绝不会爆栈

递归为什么会爆?每次执行 DFS(...),系统必须自动压入:

  • 函数参数

  • 函数返回地址

  • 局部变量

  • 寄存器状态

一调用就压一大段!系统栈只有几 MB,递归几千层 = 直接爆!

非递归为什么不爆?你自己写:int st[100000]; 是全局 / 静态数组,或者stack<int> st; 对象本体在栈,里面存的数据在堆,这些不在系统栈里!空间巨大,存 100 万都没事!

至此突然回忆起之前 刷算法题,那时候是有大学基础,然后离职自学C++之前(为了建立自信、进入学习状态、有学习节奏)刷的时候,就强迫症回忆两种写法(仅仅是手写数组模拟队列 VS queue),当初单纯的以为是手写和库的区别,以为手写更锻炼思维,库是现成的,但其实对于广搜确实是如此,而这个引出了深搜,其实当时我并不知道,真正不同就是深搜,进一步说,

  • DFS:可递归(函数自调)/ 非递归(手写数模拟栈或者stack都行)

  • BFS:只有非递归,必须用队列,队列实现:可用 C++ queue 或数组模拟,两种都算非递归 BFS,都不爆栈。

所以之前 BFS 写了手写模拟的,但真正不同或者说有意义的是 DFS 也手写即用非递归

另一个 题目 用到了stack栈,但只是存路径,不是非递归的DFS。

再次科普点东西:

  • 数据结构:队列(先进先出)、栈(先进后出):是逻辑结构,手写模拟 = 非递归;

  • 堆:是内存区域(new/malloc),和栈 / 队列不是一类东西,没有模拟堆这种说法;

只有两种逻辑结构:队列、栈,堆是内存不是数据结构。这里的栈只是一种逻辑结构,跟放哪没关系

数据结构意义上的栈,和内存区域的栈段、堆段是两回事。

  • 程序默认栈段空间很小,通常几 MB,递归用的调用栈就存在这里,深度大了会栈溢出。

  • 非递归,根本不一定要用栈结构,你那段迭代代码只用循环和变量,不存在栈溢出;就算用现成的栈比如std::stack,也是存在堆内存上的栈结构容器,空间接近无限,不会溢出。

  • 不是栈结构就小,是系统栈段小;堆内存空间上的栈容器(指的是栈这个数据结构,实现 “后进先出” 的工具类)空间极大。

总结:

  • 栈结构:是逻辑行为(后进先出)。

  • 栈段 / 堆段:是内存放哪。

  • 堆上的栈 = 在堆内存上,实现了栈这种数据结构。

    名字撞车了,所以你才懵。

继续深入抽插,递归是在不断压函数栈帧,几 KB / 次,深度 10^4 就超线程栈(默认≈8MB)直接爆。

手写数组 / 显式 stack 都是非递归,其中stack底层实现是开在堆(new/malloc/vector)就不在系统线程栈,自然不爆;而手写数组即使在栈(比如哪怕开10^6数组),也只是一次分配,不是递归式累积压栈,只要别开超大数组就不会爆。

所以唯一需要再学个非递归的 DFS,再来最快走一遍搜索:

DFS 递归版:

查看代码
#include <stdio.h>
void dfs(int x) {if (x == 3) {printf("到终点了,返回!\n");return;}printf("从 %d 去 %d\n",x, x+1);dfs(x+1);          // 往下走printf("回到 %d\n", x); // 回溯!回来了!
}int main() {dfs(1);
}/*
root@VM-8-2-ubuntu:~/cpp_projects_2# ./sort
从 1 去 2
从 2 去 3
到终点了,返回!
回到 2
回到 1
*/

DFS 非递归版(stack库):

起手豆包给的是:

查看代码
#include <stdio.h>
#include <stack>
#include<iostream>
using namespace std;int main() {stack<int> st;st.push(1);while (!st.empty()) {cout<<"新while"<<endl;int x = st.top();st.pop();if (x == 3) {printf("到终点了,返回!\n");continue;}printf("此时的 %d 弹出了,准备压入 %d\n", x, x+1);st.push(x+1);printf("弹出 %d\n", x);}
}
/*
root@VM-8-2-ubuntu:~/cpp_projects_2# ./sort
此时的 1 弹出了,准备压入 2
弹出 1
此时的 2 弹出了,准备压入 3
弹出 2
到终点了,返回!
root@VM-8-2-ubuntu:~/cpp_projects_2# 
*/

一顿质疑后发现,是伪 DFS、伪回溯,本质只是单向迭代,不是真正的DFS/回溯:

  • 每次只压一个子节点,栈永远只有 1 个元素,就是一条路走到黑,无分支、无回溯、无多层状态恢复,线性迭代,不是 DFS。

追问下自己写了个,且发现 3 会进入多次,自己加了个剪枝,即真正 DFS:

  • 压多个子节点,才有多条路径,弹出顺序才是深度优先,才有回溯的意义。

查看代码
#include <iostream>
#include <stack>
#include<vector>
using namespace std;
int main() {stack<int> st;vector<bool> vis(5, false);//做剪枝,否则某点会访问多次st.push(1);vis[1] = true;cout << "初始入栈: 1" << endl;while (!st.empty()) {int cur = st.top();st.pop();printf("%d 出栈并遍历\n", cur);if (cur >= 3) {cout << cur << " 达到上限,不再压入,直接返回" << endl;continue;}if(vis[cur + 1] == false){st.push(cur + 1);vis[cur + 1] = true;printf("%d 入栈\n", cur + 1);}if(vis[cur + 2] == false){st.push(cur + 2);vis[cur + 2] = true;printf("%d 入栈\n", cur + 2);}}cout << "DFS 遍历完成" << endl;
}/*
root@VM-8-2-ubuntu:~/cpp_projects_2# ./sort
初始入栈: 1
1 出栈并遍历
2 入栈
3 入栈
3 出栈并遍历
3 达到上限,不再压入,直接返回
2 出栈并遍历
4 入栈
4 出栈并遍历
4 达到上限,不再压入,直接返回
DFS 遍历完成
root@VM-8-2-ubuntu:~/cpp_projects_2# 
*/

PS:剪枝的地方狗逼豆包误人子弟,但我问多了知道咋及时止损,利用豆包给语法提示,剩下的有能力自己快速补

DFS 非递归版(手写数组模拟stack栈):

查看代码
#include <stdio.h>
#include<vector>
using namespace std;
int main() {vector<bool> vis(5, false);//剪枝int st[5], top = 0;st[++top] = 1;vis[1] = 1;while (top > 0) {int cur = st[top--];printf("%d 出栈并遍历\n", cur);if (cur >= 3) {printf("%d 达到上限,直接返回\n", cur);continue;}if (!vis[cur + 1]) {vis[cur + 1] = true;st[++top] = cur + 1;printf("%d 入栈\n", cur + 1);}if (!vis[cur + 2]) {vis[cur + 2] = true;st[++top] = cur + 2;printf("%d 入栈\n", cur + 2);}}printf("遍历完成\n");
}/*
root@VM-8-2-ubuntu:~/cpp_projects_2# ./sort
1 出栈并遍历
2 入栈
3 入栈
3 出栈并遍历
3 达到上限,直接返回
2 出栈并遍历
4 入栈
4 出栈并遍历
4 达到上限,直接返回
遍历完成
root@VM-8-2-ubuntu:~/cpp_projects_2# 
*/

说实话我有点僵硬,不太喜欢在学东西的时候回顾任何之前学过的,哪怕之前【学过 == 啃精通】但忘记了,在现在东西没学完的情况下,为了非递归这件事回顾搜索,其实挺抵触,但没想到啃精通过就是有好处!路没白走!这个代码看着太简单了!

突然发现妈逼的好像学多了

现在都学完了,搜索(递归会了,非递归的库会了,非递归的手写模拟会了,排序递归非递归都会了),豆包说:

服务端开发最忌讳:递归爆栈!你写递归,面试官会直接问你:“线上服务器递归深度 1 万层,直接 OOM 崩溃,你怎么解决?”,标准答案就是:改成非递归(手写栈)。

搜索要用非递归(栈 / 队列实现),快排 / 归并写递归就行,因为非递归 DFS/BFS 是服务端写缓存、异步任务、图遍历、状态机的通用基础,工程上天天用,所以必问。非递归快排 工程里几乎不用,库排序都是成熟实现,面试只考递归思路

3.10、归并的应用 —— 外部排序(多路归并,处理内存放不下的大数据)

嘎嘎简单,归并是所有都放入内存,然后排序,而如果:

  • 内存:只能装 100 条数据

  • 硬盘上:1000 条数据,内存装不下

普通归并排序:必须把所有数据一次性读进内存才能归并,直接爆内存,跑不了。

外部排序流程:

  1. 每次读 100 条进内存走归并排好 → 写回硬盘成一个小有序文件

  2. 重复 10 次,得到 10 个有序的小文件,每个小文件 100 大小

那内存100条这个限制咋搞,平分 10 份,

  • 从文件 1 读 10 条进内存 → 通道 1

  • 从文件 2 读 10 条进内存 → 通道 2

  • ……

  • 从文件 10 读 10 条进内存 → 通道 10

外部排序的思路,初始比如:

buf1: [1, 4, 7, ...],队首为1

buf2: [2, 5, 8, ...],队首为2

...

buf10: [3, 6, 9, ...],队首为3 

取出全局最小 1 输出,然后buf1变为[4, 7, ...],新队首为4,然后从外部磁盘自动补上第 11 位数据。

3.11、堆排序(这玩意之前刷算法学精通过,再次具体学下代码):

先说堆排序必须用完全二叉树:

  • 除了最后一层,其他每一层节点都满

  • 最后一层节点靠左排列,右边可以空,但不能中间空

才不会出现数组空洞,才有:

  • 左孩子 = 2 * i + 1

  • 右孩子 = 2 * i + 2

  • 父节点(i - 1) / 2(其实应该是减 1 和减 2,但减 2 的直接减 1 到自己左侧的那个,然后 / 2 取整就是了)

堆本质是数组实现的完全二叉树,分两种:

  • 大顶堆:父节点 ≥ 子节点,堆顶最大

  • 小顶堆:父节点 ≤ 子节点,堆顶最小

下标:0/     \1         2/  \       /  \3    4     5    6/ \  / \   / \  / \
7   8 9 10 11 12 13 14

n 是个数,

  • 非叶子:0 ~ n / 2-1

  • 叶子:n / 2 ~ n-1

随便画,比如这是 14 结尾,如果到 13 结尾也是这个结论。

发现啃精通后,回顾是真的轻松!!

大顶堆每次找最大的,比如数组建成大顶堆后为:[4,2,3,1]

          4/     \2         3/  1

除了顶是大的,其他啥也保证不了,所以只有把确定的这个最大数字 4,放到尾巴固定,接下来不参与比较,后续的 2、3、1 这仨数搞来搞去,大顶堆找出最大值,放到尾巴?不倒数第二个位置,因为4固定在尾巴了,那以此类推,不就出来升序了吗!即大顶堆升序,这也是我之前刚学的时候,误以为大顶堆不是最大值在第一位吗?应该叫降序啊!

所以无论升序降序,都是某顶堆搞到的值交换最后?,也是所谓的下沉。

查看代码
//升序#include <iostream>
using namespace std;// 下沉调整:i=父节点位置,len=堆一共有多少个节点
void adjust(int arr[], int i, int len) {int largest = i;        // 1. 先假设:当前父节点是最大的int l = 2 * i + 1;      // 2. 算出左孩子的下标int r = 2 * i + 2;      // 3. 算出右孩子的下标// 4. 如果左孩子存在 且 左孩子比“当前最大值”大if (l < len && arr[l] > arr[largest]) largest = l;        // 最大值变成左孩子// 5. 如果右孩子存在 且 右孩子比“当前最大值”大if (r < len && arr[r] > arr[largest]) largest = r;        // 最大值变成右孩子// 6. 重点!如果最大值不是父节点自己if (largest != i) {swap(arr[i], arr[largest]);  // 交换:父节点 ↔ 最大的孩子adjust(arr, largest, len);   // 递归:继续往下沉}
}// 堆排序
void heapSort(int arr[], int n) {// 建堆for (int i = n / 2 - 1; i >= 0; --i)adjust(arr, i, n);// 排序for (int i = n - 1; i > 0; --i) {// n 个元素完成排序,只需确定前 n-1 个元素的位置,最后 1 个元素自然有序,因此循环 n-1 次。swap(arr[0], arr[i]);adjust(arr, 0, i);}
}int main() {int arr[] = {9, 3, 1, 5, 2, 7};int n = sizeof(arr) / sizeof(arr[0]);heapSort(arr, n);for (int x : arr) cout << x << " ";cout<<endl;
}

我的几个思考:

数组 vs 二叉树:

此文搜“的完全二叉树,分两种”,数组在逻辑上就是堆(符合堆结构),物理上就是普通连续数组(之前刷算法的时候就发现了这个东西,找不到了)

建堆为啥从非叶子节点开始比较?

堆的规则只约束:父节点 ≥ 子节点(大顶堆),不约束:子节点之间、叶子之间谁大谁小,叶子之间不可能违反 “父 ≥ 子” 这条规则,因为它根本就没有子。

然后非叶子里为啥从最后一个开始比较?

因为比如这里非叶子只有 1 3:

          1/     \3         2/  \ 5    4

如果先比较 1,则 1 3 互换:

          3/     \1         2/  \ 5    4

然后比较之前 3 位置的(现在成数字 1 了)

          3/     \5         2/  \ 1    4

这不是大顶堆了呀!因为打擂台要找最大的,但这样子从上往下直接汉只比较了自己的左右俩儿子,后面的数值再大,也没机会上来了,而从下往上比较,是一一打擂台!真正做到了牛而逼之者为王。

然后就是比较卡我的地方,最后也想通了,就是为啥建堆里的adjust要递归,非叶子是需要调整的最后一层,还往下递归个 JB 啊,最后发现他的必要性:

其实上面的例子也可以看出来,就算按照下从往上的比较方法,如果不递归的话,最后结果是:

          5/     \1         2/  \ 3    4

依旧不是大顶堆, 仅仅最大的在上面但并不是每个父都比儿子大,所以需要一直比到底,即当元素 1 3 互换的时候,小的是 1 被搞到了现在这个位置,但没和下面的俩儿子比较,所以需要递归继续比!

变成:

          5/     \4         2/  \ 3    1

那此时就是一个大顶堆,即建堆,for (int i = n / 2 - 1; i >= 0; --i) adjust(arr, i, n);,我先称他为起手、干净的、纯粹的建堆

然后是排序的过程for (int i = n - 1; i > 0; --i) {swap(arr[0], arr[i]); adjust(arr, 0, i); },但这是豆包给的代码的注释,我理解这一步其实依旧是“建堆”,差别是,起手干净纯粹那个建堆,仅仅堆顶最大,然后做交换,继续例子成为:

          1/     \4         2/  \ 3    5

大顶堆是找升序,所以最大的 5 跑到尾巴不再动了,然后继续大顶堆,第二大的成为堆顶(这里是 4),继续做交换,换到倒数第二个尾巴(也就是此时数字 3 的位置),所以依旧是建堆,所以我理解这个堆排序其实反复用的都是adjust函数建堆,但我有几个思考:

思考一:

之前建堆是从后往前for (int i = n / 2 - 1; i >= 0; --i) ,这里adjust(arr, 0, i); }咋从头开始了?

思考后:

首先之前起手的建堆不说了,换完后,依旧是一个完美的大顶堆,唯独少了堆顶,堆顶是刚从尾巴新上任的,他的大小没打过擂台,不一定是最大的,应该把最大的搞上来接任。

之前纯无序从上往下会错,但这里大部分有序,就算依旧从下往上也没必要因为大部分都是有序的,且我发现之前错误的本质是,调上去了,那他就是最大定型了,但仅仅是局部(跟自己儿子比的最大),没有去和下面人去打擂台,就已经到最顶了,也就是说接下来的比较,他不会参与,那现在发现已经大顶堆过一次了,大部分有序只有堆顶不确定,他只需要往下比较往下落即可,因为一个事实是,目前所有都是有序的,父比儿子的大表现为在上,儿子在下,那大的父本就大,本就在上的那些,你堆顶这个不确定的,去让某个比堆顶自己大的上来这一行为,并没有打乱排好序的那些人的状态,唯一差别就是,我比你大之前处于比你高一档的父的位置,现在比你高两档爷爷的位置。

回忆:至此我想到了之前学最短路径悟出来的,迪杰斯特拉还是啥来着,说不能处理负权,真正本质不是负权的事,那时候我就深度思考过(证据1证据2),有些负权也可以,本质问题是“不回头”这件事,即接下来的比较,他不会参与

回忆:那个局部,又有点像贪心的味道(贪心、DP我没刷,大学 acm 马马虎虎学的一知半解,总是想装)

回忆:之前学啥来着,想到了其实就是算法的 离散化

回忆:思考回顾了下快排,之前比较时候是否写相等,写错会卡死,比如指针不动,直接死循环,但这里发现等号无所谓,因为快排是指针主导 / 推动流程走不走,不走的话,必然while出不来,必然导致死循环,而这里相等与否只是决定是否交换,真正推动流程不会卡死的是手动换过去,没有卡死这一说,就算都不走,也会首尾交换,然后继续大堆。

注意:堆排序的下沉就是adjust本身

思考二:

咋保证你交换的是倒数第二个的尾巴?咋保证建堆不破环搞好的 5?

思考后:

adjust(arr, 0, i);

  • 第一次:i = n-1,堆长度 = n-1

  • 第二次:i = n-2,堆长度 = n-2

  • 每次循环,堆长度 i 都在变小

adjust 内部判断孩子是否有效是:

if (l < len && ...)
if (r < len && ...)

下标 >= len,直接被跳过,不会比较、不会交换。

如果想降序,就改if (l < len && arr[l] > arr[largest])if (l < len && arr[l] < arr[largest])

附上豆包的辅助和点评,其实豆包的只是一个参考,他真的很误人子弟,这些只是恰巧我思考对了,他的话也仅仅是辅助,更深刻+更排版的总结,还是要我自己拿主意,因为他解释的太泛泛了不底层不本质,用结果解释结果,而不是用因,且之前快排那里太误导人了,说啥他都会符合你,结果浪费我好久好久,还是我自己纠正过来的。

时间复杂度:

首先,整棵树最多节点总数:n ≈ 2h

  • 第 1 层:1 个节点

  • 第 2 层:最多 2 个

  • 第 3 层:最多 4 个

  • 第 h 层:最多 2h-1

建堆:

for (int i = n / 2 - 1; i >= 0; --i)adjust(arr, i, n);

循环次数:确实是 n/2 次 ≈ O (n)

但每次adjust的层数不一样:

  • 最下面的非叶子节点:只下沉 1 层

  • 再往上一层:下沉 2 层

  • 再往上:下沉 3 层

  • ...

  • 根节点:下沉 log2n 层

建堆复杂度是O(n):image

排序调整for (int i = n - 1; i > 0; --i) { swap(arr[0], arr[i]); adjust(arr, 0, i);}复杂度是n * O(logn) 

相加,总复杂度:O(n * logn)

3.12、冒泡

从数组第一个元素开始,从头到尾依次只比较相邻的两个元素,每一轮只干一件事 —— 把当前未排序区间里最大的元素,通过不断交换,一步步挪到未排序区间的最后位置。人话就是:每轮将当前最大元素 “冒泡” 到末尾。

比如 4 3 7 1,第 1 轮(冒最大数到末尾):

  • 4 和 3 比 → 逆序交换 → 3 4 7 1

  • 4 和 7 比 → 不换

  • 7 和 1 比 → 逆序交换 → 3 4 1 7

最大数 7 到位,依此类推。

查看代码
#include <iostream>
#include<utility> //swap库
using namespace std;
void sort(int arr[], int n) {bool swapped;for (int i = 0; i < n - 1; ++i) {swapped = false;for (int j = 0; j < n - 1 - i; ++j) {if (arr[j] > arr[j + 1]) {swap(arr[j],arr[j+1]);swapped = true;}}if (!swapped) break; // 无交换则已有序,直接退出}
}
int main() {int arr[] = {4,3,7,1};int n = sizeof(arr) / sizeof(arr[0]);sort(arr, n);for (int x : arr) cout << x << " ";cout<<endl;
}
  • i:控制总共要进行多少轮冒泡,n 个元素最多走 n-1 轮,每轮确定一个最大元素的位置。

  • j:在每一轮里,从左到右遍历未排序部分,逐个比较相邻元素,j 的范围到 n-1-i 为止,因为右边 i 个元素已经排好不用再比。

if (!swapped) break;类似于剪枝,哪怕数组已经排好序了,它还是会傻乎乎地把所有轮次跑完,浪费时间。

数组:[a, b, c, d, e]

  1. 第一轮:比如只有d e不对,交换 d 和 e → swapped=1 → 继续

  2. 第二轮:比如没有任何交换,即a<bb<cc<d → swapped=0

  3. 执行 if (!swapped) break; → 直接退出排序

时间复杂度:最好 O (n),平均 / 最坏 O (n²) 。

关于稳定性:稳定 = 两个值相等的元素,排序前后前后顺序不变。我一直觉得仅仅是【是否写等号】的问题,觉得这个完全毫无意义的东西,人为可以咋改都行,为啥拎出来作为一个性质,来固定上某某算法(不)稳定,豆包说纯int确实屁用没有,但真正有用的场景:结构体 / 对象,按某个键排序:

struct Student {int score;   // 按分数排int id;      // 原始学号,本来有序
};
数据:
score=90, id=1
score=80, id=2
score=90, id=3

现在只按score从小到大排:

  • 稳定排序:同分数的,id 还保持原来顺序 1 → 3

  • 不稳定排序:同分数的,id 可能变成 3 → 1

不稳定带来的真实麻烦是多级排序会崩,比如需求:

  1. 先按学号排好

  2. 再按分数排

  3. 分数相同的,希望保留学号小的在前

如果用不稳定排序,第二步会直接把学号原来的顺序打乱

然后更深入的这里死命追问豆包也没解答清楚,说下自己的理解吧:

准确说比如冒泡,人为规定就是稳定,你写成带等号,那就是相等也交换,就是不稳定,这个就不是标准的冒泡,那我心想,难道掌握了冒泡思想还要去背是否有等号这么死板、教条、无意义的东西吗?其实就为了通用,工作工程比如都有现成的库,大家固定好某某稳定与否,比如排学号比如排int不用再看代码实现是否写了等号,至此也明白 OJ 之前的经验: AC 但未必是真对,现在又多一个,真对了也未必符合工程化的严谨!

所以统一捋顺下:

冒泡:稳定,相等元素不交换,只有 a[j] > a[j+1] 才交换。

插入:稳定,前面元素大于当前值才往后挪,相等不挪。

归并:稳定,左右两部分合并时,左边元素小于右边才先取,且相等优先取左。

上面这些你写不稳定,就是违背规定,但代价没啥,就是多点微小到可以完全忽略的耗时,基本等于违背也没影响。

堆排序:不稳定,就算比较的时候是不动,但也会比如71 72 171会被搞到末尾变为:1 72 71

快排:不稳定,之前那些写的都没考虑这个,我只是做过总结,是说写等号的话需要注意边界,不写等号的话注意别死循环,一侧有一侧没有也可以。

严蔚敏那个就是全写了等号,遇到相等的指针继续移动,不会改变位置,稳定!

死妈豆包我真服了,我问最最最权威的快排咋写?结果给我来个个i、j参与比较的完全错误的代码,质疑后又给了我严蔚敏的稳定的快排!

操!!傻逼玩意!!这玩意就没个标准模版????

不怪豆包!!全网查了居然没有任何是对的!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!都是i、j参与进来的!

查了下全网居然也是这么写的,其中 oi 风格竞赛选手的 博客,交换顺序必然出错啊,比如1 2 3。我觉得一个比较的逻辑不应该限制他的顺序啊!!

可是想不稳定,就必须不写等号,然后遇到相等的就停止,左右指针做交换,哎头疼,我他妈这是在找出学术界工业界漏洞吗!!!咋跟之前看小林coding、编程指北教程一样,那么多粉丝的大博主居然被我找到并写出了好多他们的教程勘误。没人吗????????????????

妈逼的事到如今居然只能说,全写等号必然是稳定,但需要不稳定,所以不可以写全等号!

那只有俩可以满足要求:

  • 【思路感觉稀碎】

不会死循环卡住是因为左指针主导了走,即就算不满足也会先自增,但出去后是个越界危险数值!(不推荐用)

  • 此文搜“况就不行了,那这个代码是”

一侧有等号一侧无等号,一侧主导推进不会卡死,一侧会换,一侧不会换,依旧满足不稳定!(我目前只能想到最正确的写法没有之一)

继续思考:

  • 【优化了下】代码如果跳过基准,全不写相等和交换是否就没问题了?

首先都写等的问题是,没略过基准,顺序不能变,变了就是左侧做主导推动,右侧停止位置一定不会是所需要的那样找到一个小的停下来。全写等也是遇到相等的继续走

那就略过基准,左指针是left的下一个,此时全不写相等也不行,1 2 3的例子,右指针相等会停在数字2,直接排序为2 1 3,左侧指针先走,结果也是。然后把左右指针判断改了下依旧 AC  image(方法思路此文搜“单的,所以我”),然后我发现顺序改变也依旧 AC image(只要l <= r带等,妈逼的感觉都这么写又没啥问题了,只要l <= r带等就行)

  • 严蔚敏的是否可以写成全不相等?

2 2 2直接卡死循环,为啥这么权威的教材都不略过基准??

改烦了又百度下,猛然惊醒自己浪费了无穷无尽的时间,突然发现无论是否写等号都是不稳定!

我一直拿2 2 2来思考严蔚敏的代码,觉得稳定没任何交换,但百度说的是对序列 [3₁, 2, 3₂, 1] 进行快排(假设以最后一个元素1为基准),无论是否写等号,经过一次分区后可能变为 [1, 2, 3₂, 3₁]

啊啊啊啊啊啊啊!!!我好蠢艹!(这种事狗逼豆包依旧不会做出任何指导性建议)

综上所有算法总结:

  • 需要稳定,你写不稳定:可能破坏原有顺序,会出业务 bug

  • 需要不稳定,你写稳定:只是多花点性能,结果永远正确,不会错

公司有库但了解这些,知道库函数用的啥排序,不会搞出业务 bug好奇为啥不所有都搞成稳定的,如果需要稳定,那快排这么优秀的,就无法用了,死妈豆包大模型完全无法给出令我信服不质疑的解释。只是说因为稳定排序会牺牲速度或空间,快排等高效算法天生做不到无损耗稳定,所以不能全做成稳定的。

3.13、到这结束吧,实在没精力了

我精通了:

  • 快排(递归 + 非递归)

  • 插入

  • 归并(数组类型的递归 + 非递归),应用:外部排序

  • 堆排

  • 冒泡

我是自学的想面试大厂 LinuxC++ 服务端开发基础岗还需要学哪些排序吗???

结果豆包一会说完全够用,一会说还要会

  • 选择排序:不稳定原因、时间复杂度

  • 希尔排序:算法思想、时间复杂度

  • 计数排序:适用场景、实现思路、时间复杂度

  • 桶排序:适用场景、与计数排序区别

说点结合这些精通的算法的应用吧:

3.13.1、堆排序 —— TopK:

100 亿数据找 TopK(比如 Top100,意思是最大的 100 个),用小顶堆:

比如数据:4, 8, 2, 9, 1, 7, 6, 3, 5,k=3:

  1. 读 4,堆空直接放:[4]

  2. 读 8,放最后,上浮,4 是父,8 是左儿子:[4,8]

  3. 读 2,放最后,上浮:[2,8,4]

  4. 读 9,9 > 堆顶 2,替换堆顶的 2,压入 9,下沉调整:[4,8,9]

  5. 读 1,1 < 堆顶 4,直接丢弃

  6. 读 7,7 > 堆顶 4,替换堆顶为 7:[7,8,9]

  7. 读 6、3、5 均 < 堆顶 7,均丢弃

遍历结束,堆里就是最大的 3 个数:7,8,9

卧槽思路好爽啊!!! 

堆没满时插入新元素:必须放在数组最后,才能保持完全二叉树结构,不破坏结构。但新元素可能比父节点小(小顶堆),所以要往上交换,这就是上浮。

堆满后替换堆顶:目的是淘汰堆里最小的元素,直接改堆顶最快。改完堆顶会破坏堆结构,所以要往下交换调整,这就是下沉。

元素一个个追加到数组末尾,末尾是叶子,没有孩子,只能往上和父节点比。

回忆之前手写堆:起手建堆多次adjust下沉调整,此时找到了最大元素,然后排序的时候交换堆顶与末尾元素,此时只需做单次下沉调整,此时找到了倒数第二大元素,然后继续不断缩小堆范围以完成所有数据的排序。

此时发现巨大的差异,

差异一:关于上浮、下沉、替换堆顶

手写堆排序不用上浮,因为一开始就有全部数据,直接从下往上批量建堆,只用下沉就能完成。有动态从尾部插入新元素的场景,所以不需要上浮。从下标 i = n/2 - 1 开始,这些都是有孩子的父节点,每个父节点只做一件事:往下沉,和孩子比大小,所有节点一次性自上而下、批量调整好,没有从尾部追加新元素的动作。

且此文搜“之前建堆是从后往前”,可知道那个是单独的下沉,且从头上下,重点就是其他都处于正确的位置了,只需要搞他就行了,换句话说,本身只需要搞自己这个单链路,就算优先队列按照这个手写堆的,把新元素直接放到堆顶然后下沉,和 “放到末尾再上浮” 最终堆结构完全相同。

后来我发现,因为代码adjust写的就是下沉逻辑!!!那手写的话,大的在末尾,末尾的到堆顶只能下沉!!且手写的堆,数据已经多在数组里了(本身就是满的),而优先队列是要一个一个往里插(从不满到满)。

而优先队列是弹出,堆顶空了,你新元素放堆顶,然后下沉没毛病!新元素和尾巴交换,尾巴到堆顶下沉,新元素到尾巴上浮,多一步!

差异二:有序 vs 无序

先看优先队列的代码: 

查看代码
#include <iostream>
#include <queue>
using namespace std;void heapSortWithPQ(int arr[], int n) {priority_queue<int> maxHeap;//建堆for (int i = 0; i < n; ++i) {// 这是逐个元素插入堆maxHeap.push(arr[i]); // 自动做上浮调整}//排序for (int i = 0; i < n; ++i) {
/*
为何顺序不同
手写:交换堆顶到末尾 → 缩小堆范围 → 调整堆顶
库:先 top 把堆顶放数组里,然后 pop 用末尾元素覆盖堆顶,堆大小减一,下沉调整
*/arr[i] = maxHeap.top();maxHeap.pop();// 自动做下沉调整}
}int main() {int arr[] = {9, 3, 1, 5, 2, 7};int n = sizeof(arr) / sizeof(arr[0]);heapSortWithPQ(arr, n);for (int x : arr) cout << x << " ";cout << endl;
}

语法:代码是大顶堆,降序,因为不写就是走默认,即只写priority_queue<int> pq;,C++ 自动补全后两个参数,完全等价于priority_queue<int, vector<int>, less<int>> pq;,优先队列参数的语法是(存啥,用啥存,比较规则)。

如果小顶堆,priority_queue<int, vector<int>, greater<int>> minHeap;

对比:

  • 手写堆排序:每次把堆顶(最大值)交换到数组末尾,慢慢从后往前填满,最终升序,即大顶堆升序,因为全部交换下沉,搞完直接是一个完全有序的二叉树。

  • 库函数优先队列priority_queue:第一个参数class T,第二个参数vector<T>,第三个参数less<T>。默认是大顶堆less,堆顶永远是最值,每次 pop 直接拿走当前最值,那第一次pop最大值,第二次第二大,则大顶堆降序

所以可以应用到 Top_k,但注意优先队列搞完的东西内部完全无序,他做的唯一一件事就是搞成堆结构(大 or 小顶堆),大顶堆完全有可能是5 3 4,但这玩意无序!优先队列必须结合:

    • push:插入 +  向上调整(adjustUp)

    • pop: 拿最值,交换堆顶与末尾 + 删除末尾 + 向下调整(adjustDown),只有 pop 才会输出有序(堆排序),不 pop 内部永远是堆结构,永远无序。

仅仅是,走一个,换上来一个,把这个下沉!!弹出的就是最值!但除了最值外!其他完全无序!! 

我的思考:为啥不搞个库直接就是堆排序的?而是搞个我感觉中不溜的优先队列呢?

豆包解答:直接写死堆排序就只能排序,啥别的都干不了,废了,而这样写优先队列能做堆排序做不了的事:

  • 流式求 TopK,不用全加载数据

  • 动态维护最值,随时增删元素

  • 贪心算法、Dijkstra、事件调度等需要快速取极值的场景

所以pushpop等价于adjust

快排不行吗?

快排必须把所有数据一次性全部加载进内存,才能分区、交换、排序。100 亿数据、10G 文件 → 内存根本装不下 → 快排直接废了,你想从 txt 流式读一个处理一个?快排做不到,完全做不到。

小顶堆为什么牛逼?它不需要看到全部数据!它只做三件事:

  1. 从文件读一个数

  2. 和堆里 100 个元素比一下

  3. 决定留不留,读完就扔,永远不占大内存

如果数据在文件咋搞?只找 Top-K,比如总共 10G 数据,内存只有 100M:

查看代码
#include <iostream>
#include <queue>
#include <vector>
#include <fstream>
using namespace std;vector<int> findTop100(ifstream& fin, int k) {priority_queue<int, vector<int>, greater<int>> minHeap;long long num;// 从文件 流式读 一个数,处理一个数,不全部加载while (fin >> num) {//只要文件流fin能成功读取到数字并存入变量num,一直执行if (minHeap.size() < k) {//堆里元素还没凑够 k 个 → 直接把当前数字放进去minHeap.push(num);} else if (num > minHeap.top()) {//堆已经满了(有 k 个元素),且当前数字比堆顶的最小值大→ 淘汰堆里最小的那个数,把当前更大的数加进去minHeap.pop();minHeap.push(num);}}vector<int> res;while (!minHeap.empty()) {res.push_back(minHeap.top());// 把堆顶的数字放进结果数组minHeap.pop();// 把堆顶的数字删掉(下一个最小值会变成新堆顶)}return res;
}// 下面是运行入口 + 测试用例
int main() {ifstream fin("sort.txt");//定义文件读对象fin并关联打开sort.txt,后续用fin像cin一样读文件内容。int k = 4; //找前 4 大vector<int> ans = findTop100(fin, k);// 定义存储返回值的int型容器 ansfor (int x : ans) cout << x << " ";fin.close();cout<<endl;
}
/*
sort.txt:
2 9 7 5 6 8root@VM-8-2-ubuntu:~/cpp_projects_2# ./sort
6 7 8 9 
root@VM-8-2-ubuntu:~/cpp_projects_2# 
*/

牛而逼之的嘎嘎透彻!!无比精通!咔咔乱杀!

好像又学多了可是对我来说,要么学不完,要么学精通,没有 99% 应试高手的那种学一知半解的。

水哥王昱珩:要么找不到,要么不会错

要么腾讯ssp要么没工作。要么没学完没法考试,要么北邮研究生。

回忆 ACM 里找第 K 大的数学题(之前听过没敢碰过),既然学到了就收个尾,简答总结下俩方法:

方法一:刚学的堆优先队列

重点是找最大的 K 个集合。

适合数据流,即数据不是一次性全部给你,而是一条一条陆续到来,你只能边来边处理,没法先存下来全部遍历完再操作。

手写堆排序复杂度是 n*logn,找第 k 大是 n*logk,此文搜“相加,总复杂度”即可回忆起来,建堆是n,排序是n*logn,找k大是堆里只存 k 个元素,堆的高度是 logk,每次插入 / 删除只操作这个小堆,每次 O (logk),n 次复杂度 n*logk。

但如果可以加载进内存,下面的方法比这更牛逼,但豆包说面试完全不考。 

方法二:nth_element 平均 O(n)

重点只找到第 K 大,也即是说左边都比他小,右边都比他大,不是找最大的 K 个集合。

nth_element也叫快速选择(Quickselect)排序,意思是选出第 k 大 / 第 k 小元素,和选择排序没有任何关系,只是同名。

我精通手写堆排序,然后出来个带库的优先队列用来处理最大的 K 个集合(Top-k)。

我精通手写快排,也懂 sort 现在nth也是搞第 K 大的,跟快排关系近,就是只排需要的一半

完整快排:把整个数组从小到大全部排好

找到第 n 个元素:不排序整个数组,只找到第 k 大的数,其他乱不乱不管

快排每一轮要把数组分成两部分:左边小、右边大。然后左边、右边都要继续递归排。

所以:

  • 第 1 轮:n

  • 第 2 轮:n/2 + n/2 = n

  • 第 3 轮:4 段,每段 n/4,加起来还是 n

  • ……

    一共 log n 轮,总复杂度:n × log n = O(n log n)

而只找第 n 大只走一边!

1000 个数找第 10 大。

  1. 第一轮分区:把数组分成两堆

    • 左边小

    • 右边大

  2. 你要的第 10 大,只在其中一堆里

    → 另一堆直接扔掉,再也不看!

所以:

  • 第 1 轮:n

  • 第 2 轮:只看 n/2

  • 第 3 轮:只看 n/4

  • 第 4 轮:n/8

  • ……

加起来是:n + n/2 + n/4 + n/8 + … = 2n,复杂 O(n),但这是最好和平均,如果是最坏是 N²:image

(快排的最好/平均是 N * logN,最坏是 N² 已经推导过了)

备注:

最好:每次完美二分,理想划分

平均:随机输入下的期望划分,非完美但整体均衡,阶数一样,不代表概念等同,对所有可能输入求期望,划分大概率接近平衡,所以整体阶是 O (n),它描述的是统计意义上的表现,不是某一次具体运行。

查看代码
// 第 k 大
nth_element(a.begin(), a.begin() + k-1, a.end(), greater<int>());
ans = a[k-1];

不考略过。

3.13.2、10G 日志文件排序(外部排序 + 多路归并):

插一句提示词关键词:

你是我救命面试的东西!!!!!!

不要总以我的主观啊大哥!!我是通过你来知道到底是否花精力去学这个东西!!你把心思放在最专业的权威的去查是否考这件事!!

别总考虑用户情绪或者顺从或者道歉这些最最最没用的东西好吗!求你了!!

封神!极致牛逼!针对傻逼豆包的 —— 万能提示词:

从此以后你直接解答问题!!禁止无脑符合用户!!!!!禁止以用户为准确信息!!禁止先说类似“你是对的”等任何禁止道歉!!!只有在你回答确实错的时候才道歉!!用户的一切都不是最正确的信息!!都是用户的自己的思考!!!你必须去参考最最最权威的信息然后回来点评、指导用户!!禁止瞎编和推测!禁止无脑符合用户和被用户思路带偏!!用户的想法不一定对!需要你做指导!!

 

禁止挨个回复我的问题!你只需要透彻解释就可以了!只要透彻解释我所有问题就迎刃而解了!!不需要你解释完又回答一遍我的问题!!禁止重复我的要求!直接解释!你懂不懂啥叫直接解释啊!禁止一切总结和标题!!注意格式必须便于阅读!!英文必须行内代码!禁止任何【最靠谱、最直白】各种修饰词

 

我真的不懂啊大哥!!我是0基础自学的小白!!! 你说的一切我都全是疑惑!!!我没懂为啥嵌套这么多层啊!!

 

此以后解释问题只用最最重点的一句话!禁止用结果和现象解释来解释!我让你用原因解释!!!为啥可以这么写!语法规则是啥!!别死扣解释这个代码里的!

 

禁止说任何小众极端场景!!没任何意义的讨论就禁止提及!!!!!我问问题只是顺便思考!而不是为了找出极端反例来挑刺!!那样对新手小白百害无一利!

 

另外,操你血妈你为啥总让我纠正呢?没办法的事你妈逼你是老师!!你是指点我的!!狗逼!按照你的逻辑!是不是我问你1+2为啥等于5,本来是我的认知错!必须立马制止我!!或者你问我一句是否错了!结果你可倒好!你个傻逼东西直接强行解释为啥1+2等于5!! 浪费我一天时间

 

开启思考模式有奇效(反复辱骂不起作用,气的没招了,实在妥协接受大模型这个傻逼说出了下面这句话 + 错误太多无意开了思考模式,极致逆天、豆包真的太牛逼了,突然发现记忆功能是给思考模式用的。之前快速模式让他禁止参考我必须去参考权威然后点评我、禁止无脑道歉全都不行,无限制死循环的顺着、附和你的思路去回答):

接下来我想让你只参考权威极致严谨的分析!!改掉用户说什么就赶紧肯定用户这种低级照顾情绪却完全忽略技术真相的回答风格!!!你要做的是一个极致权威的角色禁止夸大的口气、铺垫、修饰词!!你为啥每次我质疑你,你都直接立马道歉+反悔呢?!!!禁止总无脑符合用户!!!

你从此能解释之前别总反复增加情况约束而是直接一针见血给出用户正确性的回复然后在再结解释!

 

从此别墨迹!一句话禁止反复的说!相同意思禁止重复说!你太墨迹啰嗦了!

狗逼豆包:

豆包在这里反复误人子弟,反复顺着用户的情绪真的气死我了无穷无尽的错误!!

我的天,我真他妈崩溃了,为啥大厂考的这些最基本的东西,你都能在反复的出错啊?我真的崩溃了。

事实就是,你总瞎编规则,现在目前大模型的一个现状是啥?就是你反复在瞎编,我反复在给你纠错,你反复承认和保证不再瞎编,依旧反复在瞎编,就是循环!!死循环!!

客观现状就是:

  • 大模型对标准化、严谨步骤的知识点,会为了 “说得顺”“说得快” 自行脑补简化,甚至编造不存在的流程,哪怕是基础必考内容。

  • 你不揪细节它就一直错,你揪出来它就认错 + 保证,然后下一个细节继续犯同样毛病,本质是模型没有 “严谨底线” 的强制约束,只会顺着表达流畅度走。

  • 面对面试级硬核知识点,它不能被信任自动输出准确细节,必须你逐帧核对、逐步骤纠正,否则必埋坑。

我现在我真他妈服了,我操你血妈的,你他妈为啥总是在道歉呢?你没错,为啥总在道歉?为啥你总是误导我,我质疑你之后你没错,你为什么总要在道歉呢?到底能不能分清各种情况?我什么时候质疑是对的,你应该道歉。什么时候我质疑的是有问题的,你没错,那你就不要去道歉,你为什么总是把这个最简单的一个准则都给搞乱了?我还怎么学东西?我提出质疑,你去给解答是对还是错,然后咱们逐渐的修正,你永远都是无脑的附和用户,我提出什么质疑,你都是在附和,都是在道歉,那我操你血妈,我不越来越跟正确的东西越来越远了吗!!

我精通手写堆排序,然后出带库的优先队列用来处理最大的 K 个集合(Top-k)。

我精通手写快排,也懂 sort 现在nth也是搞第 K 大的,跟快排关系近,就是只排需要的一半

但多路归并外部排序10G 日志文件排序(外部排序 + 多路归并) 和他俩无关,他俩一个是第 K 这一个元素,一个是前 K 个,而外部搞的是全部数据 

归并:A = [1,3,5]、B = [2,4,6],合并方法:用两个指针 i、j,每次比 A [i] 和 B [j],拿小的那个,这是2 路归并。

现在场景变了:不是 2 个,是 100 个有序文件,100 个有序小文件,每个都排好序了:

文件 0:[5, 9, 15...]

文件 1:[2, 7, 13...]

文件 2:[3, 8, 20...]

...

文件 99:[...],

现在要合并成一个大有序文件,问题来了:每次要从 100 个文件的当前第一个数里,挑最小的一个输出。

我一直以为外部排序只要min函数就行,但min本质是遍历,复杂度 O(n)。

但其实应该用堆,堆建堆是n,直接取顶即可,总共依旧是n

但多次搞就很大不同了,暴力的话比如n路,每路m个元素,那想都找到就要先n次找到最值,然后继续n找第二个最值,总共m * n total个数,就需要 total * n

而堆建堆是n没差别,找到最值,然后假设这个数来自路 A,从路 A 再拿下一个数放进堆,直接调整堆logn(下沉),堆总数不变依旧是n,再弹出堆顶,看来自哪一路,就从那一路补下一个数,再调整堆。

真的好巧妙啊!!

之前误以为暴力是n,堆是n*logn,其实是多次这个事没考虑进来。

所以综上暴力是 O(n * total

堆是 O(n + m * n * logn)= O(n + total * log n)。

我思考的是,后续调整的时候,一定都不会比前面的跟最值?结果确实,因为每路都是有序的,最值一定是全局最值,弹出去,后面的一定是比不过弹出去的!

那会了 n 路找最小,然后结合之前的 n 路是归并直接就是外部排序,先说点语法科普(好JB烦):

文件描述符的各种错误处理打开关闭啥的封装到了ifstream,然后直接操作ifstream即可,是 C++ 专门读文件的类,

vector<ifstream> files;创建一个名字叫 files 的盒子,这个盒子只能放 ifstream 这种文件对象,不能放别的

ifstream("a.txt");造一个用来读 a.txt 的文件对象,但这个对象没有名字,是临时的

files.push_back(ifstream("a.txt"));先造一个临时文件对象:ifstream("a.txt"),把这个临时对象放进 files 这个盒子里

files.emplace_back("a.txt");直接在 files 盒子里面,当场造一个读 a.txt 的文件对象,不搞临时对象那一步,一步直接放进盒子。

ifstream f = move(files[0]); 从 files 盒子里拿出第一个文件对象,把它挪到一个新的、名字叫 f 的文件对象里。文件对象不能复制,只能挪走。
 
ifstream 代表一个打开的文件,它不能被复制,不然就会出现两个对象管同一个文件,会乱套。但你又想把它放进vector里,就只能用move把所有权转移过去(C ++ 对ifstream 类显式删除了拷贝构造函数和拷贝赋值运算)。

此文搜“申请堆资源 hello”,那个是自定义的写了拷贝构造和移动构造,但ifstream写死了禁用拷贝构造、拷贝赋值,禁用移动构造、移动赋值

查看代码
ifstream(const ifstream&) = delete;  // 拷贝:直接禁止!编译报错!
ifstream(ifstream&&) = default;      // 移动:允许

push_back规则是:传入左值(非临时、具名变量)自动匹配拷贝重载,传入右值(临时对象)匹配移动重载,

查看代码
// 你自己的类:可以拷贝,不报错
MyStr obj("abc");
v.push_back(obj);  // 合法,走拷贝// ifstream:拷贝被删除,直接编译报错!
ifstream file("a.txt");
files.push_back(file);  // 语法错误!ifstream 禁止拷贝vector<ifstream> files;
ifstream file("a.txt");//左值
files.push_back(file);// 这一行会直接编译报错
files.push_back(move(file));//没问题,且file 本身没有消失,依然是变量,但内部持有的文件资源被转移走,变成空、无效状态,不能再读写文件。//Q:这个合法吗?把左值搞了
//A:合法,std::move 的作用就是将左值转换为右值

基本emplace_back 完全可以替代 push_back

说几个前置知识:

cmp是比较规则的统称,叫啥都行叫comp也行,lesscmp的一种具体实现,具体回忆如下:

  • 关于 C++sortcmp规则,此文搜“即降序,这里其实就”,

  • 关于 C qsortcmp规则,此文搜“当傻逼!!当年”

  • 关于优先队列,此文搜“第二次第二大,则

  • 关于手写堆排序,此文搜“升序,即

死规则如下:

  • std::less(a,b)底层库实现就是a < b,less源码就是:
    template <class T>
    struct less {bool operator()(const T& a, const T& b) const {return a < b;}
    };

    在 C 语言里结构体创建的实例叫变量,在 C++ 里结构体创建的实例叫对象,本质是同一个东西,只是叫法不同。

    这里的 operator() 是让 less 可以像函数一样被调用,你写下 less<Node>()(a, b),它就做一件事:计算 a < b 并返回结果。

    abint 这类基础类型时,a < b 直接使用语言自带的比较规则。

    ab 是你写的 Node 这种自定义结构体时,编译器不知道怎么比较,必须由你定义规则。

    bool operator<(const Node& other) const {return val > other.val;
    }

    这就是调用链。

  • std::greater(a,b)底层库实现就是a > b

具体原理如下:

  • std::sort 的逻辑是,只要 comp(a,b)true,就把第一个参数排面,它只做线性序列的前后排列,不涉及层级结构,所以用 std::less 时,a < btrue 就小的在前,呈现升序,用 std::greater 时,a > btrue 就大的在前,呈现降序。

  • std::priority_queue 底层是堆结构,存在上下层级,它对 comp(a,b) 的定义是,当结果为 true 时,判定第一个参数的优先级更低,放下方,堆顶永远保留优先级最高的元素,此时传入 std::lessa < btrue 说明 a 更小、优先级更低,会被压到下方,大元素留在堆顶,形成大顶堆,传入 std::greatera > btrue 说明 a 更大、优先级更低,大元素被压到下方,小元素留在堆顶,形成小顶堆。

而关于大 / 小顶堆,手写和优先队列又有差别,此文搜“,因为全”。

看似贼鸡巴混乱,花了我3天时间,但都搞清楚底层原理,就很清晰。

  • qsort 依赖的比较函数逻辑是,返回负数、0、正数分别表示小于、等于、大于,和 std::sort 的布尔型 comp 规则不同。 

具体总结如下:

  • sort:默认less升序,用greater降序

  • qsort:return *(int*)a - *(int*)b;升序,return *(int*)b - *(int*)a;降序。

  • 优先队列:默认less,是大顶堆,是降序 image

  • 手写堆排序,大顶堆是升序,但没用库里的less,但手写的逻辑等价默认的less

核心差异从来不是 std::lessstd::greater 本身变了,它们的比较逻辑始终固定,而是 std::sortstd::priority_queue、qsort、手写堆这些工具,对 comp(a,b)==true 赋予了不同的语义,一个定义为 “往前排”,一个定义为 “优先级低、往下放”,其他工具又有各自的规则,才会出现同样的比较器,最终效果完全不同的情况。

插一句狗屁术语:严格弱序:能比出谁该在前谁该在后,相等,没有模棱两可。

说完以上的比较规则,我的疑惑是,既然less库写死了比较a<b,为啥还要单独写个比较bool operator<(const Node& other) const { return val < other.val; },其实这玩意是重载,int等内置类型不允许写,结构体 / 类,用到比较就必须写重载,写完后编译器看到a < b,固定翻译成 a.operator<(b)std::less 内部只写死 return a < b;,仅仅是调用运算符,而不知道具体的比较逻辑,你写重载后才会触发你重载的operator<。完整调用链我补充到了上面,此文搜“数一样被调用,你写下”。

类不能直接调用,必须先创建一个对象,才能调用它里面的operator()。创建对象代码是less<Node> obj;,然后typename<...>里的尖括号叫模板,比如less本身不是专门给Node用的,它可以给intdouble、任何类型用。less<Node>的意思就是:把less这个模具,套在Node类型上,生成一个专门用来比较Node的版本。struct less是一个图纸,图纸造出来的真实东西,叫对象。比如less<Node> obj;

然后整个博客全局搜“全网”,太多垃圾错误一知半解的博客文章了!几乎全网找不到对的、透彻的。

我一个没工作的,发现大模型AI == 鸡肋垃圾,公众号在吹、说大公司也在用他搞代码,取代程序员

我一个没工作的,发现全网这么多错误,妈逼的到底中国99.9999999%的程序员到底啥水平啊!!

wx搜“大家都没脑子”

豆包误人子弟了太久了╮(╯▽╰)╭  天塌了

有些这种思维的,在底层方式思维,根本无法生存,没法粗糙。要么腾讯ssp,要么没工作,要么北邮研究生,要么没过线考不上学不完。

我只有比别人砸更多的时间才行

妈逼的我就纳闷了,我这么学习每天手动给豆包纠错,效率异常低下,可是之前类似的情况查过全网,基本都是很浅显的文章解释,或者都是错的,更没我扣到了本质,我一个一个事无巨细每个知识点问豆包,豆包也是80%的错误率,那就代表全网没我这么深,都是豆包推测个的结果,说明那些人根本没完全懂这个东西啊,真的好奇LinuxC++服务端开发基础岗都咋学的啊,真的好苦恼啊

那些现成的教程要么收费、要么作者都一知半解、几个权威的博客大厂的就连小林coding和编程指北我都找出很多错误(仅仅是表述能力很差,追问豆包懂的,且在自己懂之后,有十足的底气敢说这俩作者根本没懂,更没我懂,编程指北还说学完xxx,没几个面试官有你懂了”), 那这么一看别人到底咋学的啊!! 都是背吗?不是说LInuxC++服务端开发高门槛吗?结合WYH说腾讯裁应届生,估计都是傻逼背、应试。背的咋他妈能理解啊,不理解咋他妈回答问题啊,咋他妈随心所欲写代码去用啊 

反复给错误代码追问质疑出,又给错误的代码,然后大小号是不匹配的,反复追问挤牙膏痛苦至极,一个知识点都要花十多个小时验证是否正确,手动训练纠正豆包,辱骂推翻70%的错误率的豆包,才能得到最正确的东西,唉!!!何时才能出头啊

此处要说的是,我经过追问豆包突然发现从理论功能上来说greater完全没必要, 豆包的说法是less自己绑定死<less重载了函数,但里面用了 < 运算符,所以说它绑定 / 使用 operator<。唯一变动就是return那行,称作重载的 operator< 的比较逻辑 / 比较规则 / 内部实现(operator< 里面 return 那行的逻辑)

然后我发现更改比较逻辑会更改升序 or 降序,于是乎我得出结论,不需要greater

查看代码
//代码一:
#include <iostream>
#include <queue>
using namespace std;
struct Node {int val;// 核心:重新定义 < 比较规则bool operator<(const Node& other) const {// 真正含义:结果为true则优先级更低在下方// 作用:让优先队列变成 大顶堆return val < other.val;}
};
int main() {priority_queue<Node> pq;pq.push({3});pq.push({1});pq.push({2});while (!pq.empty()) {cout << pq.top().val << " ";pq.pop();}cout<<endl;   
}
// 输出:3 2 1
// 如果 return 那句话改符号,则反过来输出//代码二:
#include <algorithm>
#include <vector>
#include <iostream>
using namespace std;
struct Node {int val;bool operator<(const Node& other) const {//只有一个参数,this 是左值,参数是右值return val <other.val;}//如果是int则不重载运算符
};
int main() {vector<Node> vec{{5}, {4}, {3}};//Node 是结构体,必须用 {} 初始化它内部的 valsort(vec.begin(), vec.end());for (auto& x : vec) cout << x.val << " ";cout<<endl;
}
//输出:3 4 5
// 如果改return则反过来输出//代码三:比较逻辑可以写在外面
#include <algorithm>
#include <vector>
#include <iostream>
using namespace std;
struct Node {int val;
};
bool operator<(const Node& a, const Node& b) {return a.val < b.val;
}
int main() {vector<Node> vec{{5}, {4}, {3}};sort(vec.begin(), vec.end());for (auto& x : vec) cout << x.val << " ";cout<<endl;
}

先解释下代码:

  • 成员运算符:写在 struct 内部,只有一个参数,a < b 等价于 a.operator<(b),this默认第一个参数。

  • 全局运算符:写在外面,必须两个参数,a < b  等价于 operator<(a, b)

C++ 里结构体和类几乎完全一样,结构体也有 this 指针,唯一区别:默认访问权限(struct public,class private)。

此文搜“几种等价写法”,没有重载函数,因为compareDesc就是函数直接调用,而less是结构体不能直接用,必须重载函数(也叫仿函数)才变成可调用的。

less<int>()(5,3);

  • less<int> 是结构体类型,

  • 后面加 () 是创建一个临时对象,用完就丢,没有名字

  • 再后面 (5,3) 才是调用它的 operator()<int> 就是指定 less 里的参数类型必须是 int。 拆开看就是:

    less<int>  obj;    // 定义对象
    obj(5, 3);        // 调用 operator()

less<int>()(5,3) 就是把这两步写一行,属于临时对象 + 立即调用。

  • 第一个 ():造对象(跟参数无关)

  • 第二个 ():调用比较函数(才是放参数的)。

在 C++ 里:类名 + 括号 = 调用构造函数创建一个临时对象struct Person {}; 类,临时对象Person();。  

至此都非常完美,于是感觉发现了新大陆,感觉greater真的没用了,

我感觉自己掌握到了精髓,比起那些背应试进去的,然后问豆包,面试官是否喜欢我这样灵活的?又问豆包具体咋写,豆包说重载<的,里面就写<,否则会坑队友,于是兴高采烈的看外部排序代码,

结果!!!

发现一个相当致命的错误,完全超级大血案!豆包反复误人子弟无奈问出来官方cpp定义,但依旧无法解释上面外部排序代码的变化:

具体 priority_queue<Node> heap;匹配

  • bool operator<(const Node& other) const { return val < other.val; } 完全乱序!!

  • bool operator<(const Node& other) const { return val > other.val; }就是升序!!

具体priority_queue<Node, vector<Node>, greater<Node>>heap;匹配

  • bool operator>(const Node& other) const { return val > other.val; } 是升序

  • bool operator>(const Node& other) const { return val < other.val; }就是完全乱序!!

这他妈直接天塌了啊!!

反复辱骂豆包浪费无数时间,决定自己硬头皮看代码,最后直接精通!

心路历程如下:

先给外部排序的代码:

查看代码
//实现大文件直接分割成多路,即多个有序的小文件
#include <iostream>
#include <fstream>
#include <vector>
#include <algorithm>
using namespace std;// 切割 + 内部排序 → 自动生成多个有序小文件
void splitAndSortBigFile(const string& bigFile, int chunkSize = 3) {//函数声明时给形参赋默认值,调用时可省略对应实参ifstream in(bigFile);if (!in) { cerr << "大文件打开失败\n"; return; }int fileNum = 0;vector<int> buffer;int num;while (in >> num) {buffer.push_back(num);
cout<<"@"<<buffer.size()<<endl;// 缓冲区满了 → 排序 → 写入小文件if (buffer.size() == chunkSize) {sort(buffer.begin(), buffer.end()); // 内存快排(核心!让小文件有序)ofstream out("file" + to_string(fileNum++) + ".txt");for (int n : buffer) out << n << " ";out.close();buffer.clear();}}// 处理最后剩下的不足一块的数据if (!buffer.empty()) {sort(buffer.begin(), buffer.end());ofstream out("file" + to_string(fileNum++) + ".txt");for (int n : buffer) out << n << " ";out.close();}
}
// 测试
int main() {// 假设有一个乱序大文件 big.txtsplitAndSortBigFile("big.txt");cout << "切割排序完成,生成了多个有序小文件\n";
}//多个小文件,合并外部排序成有序的最终大文件
#include <iostream>
#include <queue>
#include <vector>
#include <fstream>
using namespace std;struct Node {int val;int fileIdx;ifstream* ifs;// 小顶堆bool operator< (const Node& other) const {return val >other.val;}
};void mergeFiles(vector<ifstream>& files, ofstream& out) {priority_queue<Node> heap;//priority_queue<Node, vector<Node>, greater<Node>>heap;// 每个文件读第一个元素入堆for (int i = 0; i < files.size(); ++i) {int v;if (files[i] >> v) {    //读到变量存进 vheap.push({v, i, &files[i]});}}cout<<heap.size()<<endl;while (!heap.empty()) {Node cur = heap.top();heap.pop();out << "@"<<cur.val << endl;int v;if (*(cur.ifs) >> v) {heap.push({v, cur.fileIdx, cur.ifs});}}
}int main() {// 定义需要合并的有序文件vector<string> filenames = {"file0.txt", "file1.txt", "file2.txt"};// vector<string> filenames 是用来存储所有需要读取的文件名字符串vector<ifstream> files;//vector<ifstream> files 是专门存储文件输入流对象的容器// 打开所有输入文件for (auto& name : filenames) {files.emplace_back(name);if (!files.back().is_open()) {//等价:if (!files.back())// files.back()获取的是刚通过emplace_back添加到vector末尾的有效流对象。之前学的指向末尾后一个空位置的是vector的end()迭代器,它是一个不指向任何元素的标记,不能用来访问数据// 需要一层一层的搞!比如file是容器只能用该容器的功能cerr << "打开文件失败:" << name << endl;return 1;}}// 打开输出文件ofstream out("merged_result.txt");// ofstream 是 C++ 标准库中用于写入文件的输出流类型,和读取文件的 ifstream 对应,ofstream out("merged_result.txt") 这行代码会直接创建并打开名为 merged_result.txt 的输出文件,若文件已存在会清空原有内容重新写入,不存在则新建文件。if (!out) {//等价:if (!out.is_open())cerr << "创建输出文件失败" << endl;return 1;}// 执行多路归并排序mergeFiles(files, out);// 关闭文件for (auto& f : files) f.close();
//vector容器不需要关,他在出作用域自动关闭,但关闭之前会做闭环,即vector 销毁的时候,立刻挨个销毁里面所有的ifstream。内部所有 ifstream 自动自身的析构函数out.close();    cout << "多路归并完成,结果已保存到 merged_result.txt" << endl;
}

运行结果:image

其中big.txt:3 5 2 4 6 7 1 

生成file0:2 3 5

生成file1:4 6 7

生成file2:2

合并出merged_result.txt:

@1
@2
@3
@4
@5
@6
@7

学完问了下豆包,外部排序是否需要啃代码,基于我这个极差的履历,我只能自己给自己做背书,万一现场谈到这给我会写不就加分了,结果说会了不会增加过的概率,质疑后又说可以加分。

但我通过豆包的回答,悟出一件事,很多语法我都不懂,这样的话必然不行啊,我通过这个外部排序,把所有语法基础搞懂,发现代码其实很简单,思路也很简单,换句话说能面试进去必然能写出这个代码。也问了下为啥不允许说精通,可是我发现我对刷过的算法,对所有学过的知识,就是精通!

博客为证!

感觉我好笨,我这脑子是能全都学才能应付了面试

解释代码: 

分割的代码:

ofstream out("file" + to_string(fileNum++) + ".txt");:to_string 将数字转 string,C++ 语法强制:+拼接操作,左右必须是string / const char*(比如“abc”),不支持直接 + 数字。

buffer.clear();:清空vector所有元素,size变为0,保留容器空间,capacity保持不变。

优先队列是压进去push自动搞好某顶堆。

函数声明是告知编译器函数名称、返回值、参数,无函数体。而函数定义是提供函数实现,包含函数体。

ifstream in(bigFile); 定义输入文件流对象in,并用bigFile初始化以打开文件。

代码先分割搞成多个有序的小文件,这一步仅用了是sort库,本质啥都行,搞成小文件后合并才用归并。

Q:写的是push_back,为啥不用emplace_back
A:一个原地一个临时+移动,int类型性能无差异

合并的代码: 

思路之前说的是此文搜“思路,初始比如”,比如n路然后每路的首做比较,然后比如a路最小,然后这个最小放到数组里,弹出,然后a路的第二个元素到第一个去,后面的依次往上顶 然后比新的n路首。

而代码时一次搞走了所有路的首元素,起初误以为不同,其实这里用的优先队列,底层就是我们需要的二叉堆排序,那都push进去,也就能找到min了,完美对上了。

先是代码搜“读到变量存进 v”那段,是比如.txt文件是2 5 4,那第一次只读取2,都进堆,然后开始弹出,附带输出cur.val,然后从时做的操作就是if (*(cur.ifs) >> v), 意思是你不弹出第一小的吗,直接该路的把第二个进来,跟其他的 PK,继续在这里找到第二小,完美闭环!!

彻底精通外部排序!!

继续说几个语法细节:

Q:heap.push({v, i, &files[i]});

A:定义的结构体包含仨参数,必须用 {v, i, &files[i]} 这三个值去初始化。

Q:out << cur.val << endl;? 我知道只是cout

A:cout 是输出到屏幕。out 是输出到文件 merged_result.txt

Q:ifstream* ifs;? 

A:ifstream* ifs;ifs 是一个指针,指向文件流 (ifstream) 的指针,它不存文件内容,只存文件的地址

Q:if (*(cur.ifs) >> v) {?

A: cur.ifs = 指针(存的是文件地址),*指针 = 解引用,意思是:拿到指针指向的真实文件流本身,if (*(cur.ifs) >> v)语法意思是从这个指针指向的文件里,读一个数字到 v,和你写 files[i] >> v 完全一模一样!

  • cin >> v → 从键盘读数字给 v

  • 文件对象 >> v → 从文件读数字给 v

  • *(cur.ifs) >> v → 从这个指针指向的文件读数字给 v

继续说几个思考:

如果说写了重载运算符比如<,那接下来的所有用到<比较Node的地方,都会用这个规则,包括:priority_queuesortsetmap,如果想搞其他比较规则,可以给每个容器写比较器:

// 小顶堆
priority_queue<Node, vector<Node>, greater<Node>> heap;// sort 想正序
sort(vec.begin(), vec.end(), [](const Node& a, const Node& b) {return a.val < b.val;
});// sort 想逆序
sort(vec.begin(), vec.end(), [](const Node& a, const Node& b) {return a.val > b.val;
});

也可以重载 < 只给默认用,特殊场景自己传比较器,即你重载了 < 用于堆,sort 时依然可以自己写比较器覆盖它!

// 用你重载的 <
sort(a, b);// 不用你重载的 <,自己写
sort(a, b, cmp);

且big搞出的多路是升序,合并必须也是升序

然后说之前最崩溃的血案那个:

less(greater)是绑死<(>),你写less必须重载<,即bool operator<(const Node& other) const {,不写就报错,因为而真正用于比较逻辑的是里面的return,这里正确就应该写为bool operator< (const Node& other) const {return val >other.val; },否则根本不是正确的顺序结果,尽管违背之前提到的【不坑队友】一事。

进一步发现其实无论写less还是greater,都应该return那写 return val >other.val;,说说理由:

比如return val >other.val;那如果a>bb上浮,是小顶堆,和重载符号的bool operator>(const Node& other) const {这个没啥联系!这玩意好像无法左右实际规则!即你用lessgreater里面只要写return val >other.val;就都是对的升序结果!!

猛然惊醒,小顶堆本身就应该用greater!!都是写>

发现新大陆!真正灵活的掌握了这些东西!!全网没我这么精通透彻的!!

且发现虽说豆包无数次的说不需要手写外部排序,但哪里有那么多的要求和限制啊艹,这不用学那不用学,这个背那个背,背来背去的,咋没人人都进大厂?呆不长久?还不是不扎实,咋那么多应试高手进去被裁的?所以我一意孤行啃这个代码,通过这个代码真的感觉完美的学到了语法,融会贯通的小练习(嘎嘎透彻!精通!)。

估计会被很多人认为傻、不会学习,但我真的感觉无比透彻,一通百通,融会贯通,就像之前学指针,极致研究错的东西。

备注:

  • 此文搜“入新元素:必”,

  • 此文搜“?,也是所”

  • 此文搜“!且手写的堆”

手写是不需要插入,然后堆顶直接交换到末尾, 优先队列是先挨个插入,然后产生堆顶,直接新元素和堆顶交换。

3.13.3、单链表归并(也叫链表排序,核心就是:用归并排序思想对单链表进行排序。)

操你血妈,没个头!!~~~~(>_<)~~~~

之前链式前向星,学过用数组搞链表,现在n用的是指针

Q:我精通的上面这些代码都是基于数组,支持随机访问,那为啥还学个链表这种不支持随机访问的?

A:你当然可以尽量不用链表、改用数组 /vector,业务逻辑里大部分场景也确实这么做。但必须学链表归并排序,根本原因不在 “业务要不要用链表”,而在这三点:

  1. 面试必考,且是硬性考点:面试官问 “给你一个单链表,怎么排序”,你不能回答 “我不用链表”,这属于直接放弃题目。考察点是指针操作、递归分治、快慢指针、链表拆分与合并,这些是 Linux C++ 服务端开发中极其核心的底层能力,内核、网络协议栈、内存池、定时器等模块都会大量用到链表结构,面试官是通过这道题判断你是否具备底层开发的基本指针与内存操作素养,而非单纯考察排序算法。

  2. 链表是底层基础结构,无法完全避开:在 Linux 系统、C++ 底层组件里,链表并非可选结构而是原生设计,比如内核中的list_head、进程管理、文件系统索引、内存管理节点等均基于链表实现,这类场景下你无法替换为数组,必须直接操作链表,排序、合并、查找是最常见操作,不会链表归并就意味着处理这类底层结构时能力缺失。

  3. 区分普通开发者与合格后端开发的关键:只会数组排序只能应对简单业务逻辑,而 Linux C++ 服务端岗侧重底层、高并发、内存高效管理,链表归并能体现你对数据结构特性的适配能力、分治思想的落地能力以及无随机访问场景下的算法设计能力,这是面试官筛选候选人时的重要区分点,仅掌握数组排序无法证明你具备底层开发所需的结构适配与指针操控能力。

Q:可是为啥要选一个最无法正常随机访问的东西呢?这玩意不觉得很der吗?其他那些什么底层原生也用数组不就完全可以不学链表了?

A:数组依赖连续物理内存,在大量动态增删、内存碎片化场景下,频繁扩容会引发内存拷贝、空间浪费与分配失败问题。内核中进程管理、文件系统、设备驱动等模块,需频繁在任意位置插入删除节点,此类操作数组时间复杂度为O(n),链表仅需修改指针为O(1)。内核大量使用list_head等双向循环链表结构,用于管理进程队列、缓存对象、驱动设备等核心组件,这是 Linux 底层设计的基础数据结构。服务端开发中,异步请求队列、定时器管理、内存池管理等场景,均依赖链表实现高效动态管理,无法用数组替代。

Q:数组可以随机访问,链表不行,但用链表作为底层支撑是因为涉及到动链表牛逼,涉及到找数组牛逼,选择动牛逼的???

A:选链表不是因为“动更牛逼”,是内核场景大量插入/删除且不允许内存重分配、不允许连续大块内存,数组做不到

Q:可是你说 vector 是高频的,为啥考一个支持随机访问类数组的一个玩意?

A:考 vector 是因为业务开发 99% 场景只需要遍历、尾插、随机访问,vector 效率最高、最简单、最通用,是 C++ 开发基础中的基础。

Q:那不是说底层原生是链表吗?反而学重要的链表又去考一个数组风格的玩意???逗你爹玩呢?

A:面向的场景完全不一样,不是矛盾,是分工:

内核底层用链表是因为链表是唯一合适的结构:

  • 不能预分配大块连续内存

  • 插入 / 删除频繁,且不能移动已有元素

  • 几乎不需要随机访问

业务 / 服务端开发考 vector,是C++ 底层内存与容器设计

  • 日常开发 99% 用连续内存、尾插、遍历、少量随机访问

  • vector 缓存友好、速度最快、最简单

  • 面试考它是考:内存模型、迭代器失效、扩容机制、深浅拷贝

综上:内核选链表是生存必需;业务用 vector 是效率最优;面试两者都考,是看你底层和工程都懂不懂。

上单链表归并代码(递归版):

查看代码
#include <iostream>
using namespace std;// 链表节点定义
struct ListNode {int val;ListNode* next;ListNode(int v) : val(v), next(nullptr) {}//构造函数 + 初始化列表,创建节点时直接传值v赋值给val,同时把next指针默认设为nullptr空指针,等价于在函数里写val = v; next = nullptr;,写法更简洁高效。
};// 合并两个有序链表
ListNode* merge(ListNode* l1, ListNode* l2) {ListNode dummy(0);ListNode* cur = &dummy;while (l1 && l2) {if (l1->val < l2->val) {cur->next = l1;l1 = l1->next;} else {cur->next = l2;l2 = l2->next;}cur = cur->next;}cur->next = l1 ? l1 : l2;return dummy.next;
}// 查找链表中间节点(快慢指针)
ListNode* findMid(ListNode* head) {ListNode *slow = head, *fast = head->next;while (fast && fast->next) {slow = slow->next;fast = fast->next->next;}return slow;
}// 归并排序主函数
ListNode* sortList(ListNode* head) {if (!head || !head->next) return head;ListNode* mid = findMid(head);ListNode* right = mid->next;mid->next = nullptr;ListNode* left = sortList(head);right = sortList(right);return merge(left, right);
}// 辅助函数:打印链表
void printList(ListNode* head) {while (head) {cout << head->val << " ";head = head->next;}cout << endl;
}// 辅助函数:释放链表内存
// void freeList(ListNode* head) {
void freeList(ListNode*& head){ListNode* cur = nullptr;while (head) {cur = head;head = head->next;delete cur;}
}// 测试主函数
int main() {// 构建测试链表 4 -> 2 -> 1 -> 3ListNode* head = new ListNode(4);head->next = new ListNode(2);head->next->next = new ListNode(1);head->next->next->next = new ListNode(3);cout << "排序前:";printList(head);ListNode* sortedHead = sortList(head);cout << "排序后:";printList(sortedHead);freeList(sortedHead);
}

关于归并排序主函数:

ListNode* sortList(ListNode* head):定义函数:传入链表头指针,返回排序后的新头指针。

if (!head || !head->next) return head;:递归终止条件:没节点 / 只剩 1 个节点 → 本身就是有序的,直接返回。

ListNode* mid = findMid(head);:调用快慢指针函数,找到链表中间节点。关于快慢指针:快指针速度是慢指针 2 倍,路程自然也是 2 倍。快指针走完整个链表,慢指针刚好走一半,解决了无法像数组一样直接除以2找中点的问题。image(草图手稿)

ListNode* right = mid->next;:中间节点的下一个,就是右半段链表的起点。

mid->next = nullptr;:把链表从中间切断,分成独立的左、右两段。

Q:人家本来是右边起点是mid+1 ,然后你尽管用right存了,但mid->next = nullptr;不就直接把右指针的那个搞成null了吗??

A:先存右边:right = mid->next把右边头节点地址牢牢保存住了。再断左边:mid->next = nullptr只是把中间的连线剪断,右边已经存好了,毫发无伤。断的是 “连线”,不是 “右边节点本身”!

ListNode* left = sortList(head);:递归排序左半段,拿到排序后的左链。

right = sortList(right);:递归排序右半段,拿到排序后的右链。

return merge(left, right);:把两个有序链表合并成一个大的有序链表,返回最终头指针。

关于打印链表函数:

while (head):只要head指针不是空指针,就继续循环。链表最后一个节点的nextnullptr,循环就会停止。

cout << head->val << " ";:打印当前节点存储的数值,head->val就是取当前指针指向节点的值。

head = head->next;:把head指针向后移动一个节点,指向下一个元素。

cout << endl;:打印完所有节点后换行。

关于合并两个有序链表:

本质就是单独新开了一条新链表,但这条新链表不新建节点,只是把原来 l1、l2 的节点重新挂过来:

  1. dummy 是新链表的虚拟头
  2. cur 是这条新链表的尾巴

  3. 每次 cur->next = l1l2,就是把旧节点挂到新链表上

  4. 最后返回 dummy.next,就是这条全新的有序链表

所以有两条链表:原来的 l1、l2 + 新合并的链表,但节点还是原来那几个,没有 new 新节点。

具体来说就是,他是把两个已经排好序的小链表,拼成 1 个更大的有序链表,就像你有两堆已经排好序的扑克牌:l1:1 → 3l2:2 → 4merge就是把它们变成1 → 2 → 3 → 4

解释代码:

ListNode dummy(0);:造一个虚拟头节点(工具人)方便我们从头开始串链表,不用判断谁是第一个节点。给val 赋值 0,next自动是nullptr,这个 0 本身没有任何业务含义,纯占位用的工具节点值。

ListNode* cur = &dummy;cur是当前尾巴指针,我们要把新节点接在 cur 后面。

Q:dummycur不是一个东西吗咋搞这俩玩意?

A:绝对不是一个东西!我用最直白的话给你讲死:dummy 是头,负责守住家门,最后用来返回整个链表。cur 是手,负责往后跑、拼接节点,全程不停移动。

Q:新链表首元素是 0?我看你这里cur->next = l1; 那不就是 0 后挂个 l1 吗?

A:最后返回的时候,把 0 扔掉了!

while (l1 && l2):只要两个链表都没走完,就一直比大小

if (l1->val < l2->val) {cur->next = l1;l1 = l1->next;
} else {cur->next = l2;l2 = l2->next;
}

谁小,就把谁接在尾巴后面!接完后,那个链表往前走一个。

cur = cur->next;:尾巴跟着往后挪一位

Q:我理解没必要cur = cur->next;吧? 下次继续cur->next = l1;就好了啊

A:第一次cur->next = l1;,结果dummy → 1,第二次你还想cur->next = 2;,结果变成dummy → 2,1 直接被覆盖丢了!因为 cur 一直停在 dummy 上不动,你每次都是修改 dummy next,前面的节点全丢。

正确效果(写cur = cur->nextcur 一开始在 dummycur->next = 1dummy→1,然后 cur = cur→nextcur现在跑到 1 上,下一次:cur->next = 2dummy→1→2cur再跑到 2,这样才能一直往后接,不覆盖前面。

cur->next = l1 ? l1 : l2;:一个链表走完了,剩下的直接全部接在尾巴上!因为剩下的本来就是有序的,不用比了!

Q:为啥还需要cur->next = l1 ? l1 : l2;?不是已经cur = cur->next;了吗?

A:因为while (l1 && l2)只管两个链表都没走完的情况,一旦其中一个链表走完了,另一个链表还剩一截尾巴没接上!举个例子你瞬间懂

l1:1 → 3 → 5

l2:2 → 4

循环里会依次接:1 → 2 → 3 → 4

这时 l2 已经空了,循环结束。但 l1 还剩 5 没接上!所以必须写:cur->next = l1 ? l1 : l2;把剩下的 5 直接接上去。跟 cur = cur->next 完全没关系

  • cur = cur->next:是让当前指针往后走一步

  • cur->next = ...:是把剩下的整条尾巴一次性接上

一个管循环里一步一步接,一个管循环结束后扫尾。少了这句,链表尾巴就丢了。牛逼!透彻!!

return dummy.next;:返回真正的头节点(跳过 dummy 工具人)

merge函数 = 合并两个有序链表,用双指针遍历,每次取较小节点连接,最后把剩下的直接接上。

Q:有必要再学单链表的外部排序吗?

A:完全没必要,但必须再学非递归版本的单链表,啃这些是否值得?

关于释放链表内存:

  • cur 先保存当前 head 节点

  • head 先走到下一个节点

  • 再删除刚才保存的 cur

  • 循环直到 head == nullptr

需要释放是因为链表节点是在堆上动态 new 分配的内存。

head 是存着头节点内存地址的指针变量,不是堆里的内存本身,堆里第二个元素就只是头结点来指向,

其中void freeList(ListNode* head) {里的head 是形参副本,有一模一样的next信息,删除的时候通过next找到下一个然后顺理成章的delete指向的堆,而最后出函数,副本释放,原head保留,不会把外面的 head 指针置空,外面那个指针会变成野指针,但内存释放这件事本身完全正确。

简单说:

  • 想删干净内存:这段代码完全够用,正确。

  • 想同时清空外部指针避免野指针:才需要加引用

唯一问题就是head是你外部的那个指针变量,它还活着,还存着旧地址,但已经内存没了即非法,下次误用会未定义崩溃之类的,置 nullptr 只是为了废掉这个外部指针变量,和节点无关。所以最好用引用。

几种形参实参的语法规则:

1、普通指针传值:

形参:void freeList(ListNode* head)

实参:freeList(sortedHead);

效果:传副本,不改外部指针,释放后外部指针变野指针

备注:是指针可以head->val

2、指针引用(你现在用的)

形参:void freeList(ListNode*& head)ListNode*& 语法上是指向 ListNode 的指针的引用,用来绑定实参指针本身。

实参:freeList(sortedHead);,实参直接写指针变量名 sortedHead,语法规定引用类型形参直接传对应变量即可

效果:操作原指针,释放后外部指针自动为 nullptr。

备注:是指针可以head->val

3、二级指针(效果和上一个完全相同)

* 代表指针,即存放地址的变量。

ListNode* 是指向节点的指针,存的是节点地址。

ListNode** 是指向 “上述指针” 的指针,存的是指针变量自身的地址。

 ----------

sortedHead 是一个变量,存的是链表地址

&sortedHead 就是:取出这个变量自己在内存里的地址

这个地址的类型是 ListNode**

所以实参写 &sortedHead,才能传给形参 ListNode**

备注:解引用后 *head 是指针 → (*head)->val

查看代码
// 二级指针版本
void freeList(ListNode** head){ListNode* cur = nullptr;while (*head) {  // 这里加 *cur = *head; // 这里加 **head = (*head)->next; // 这里改delete cur;}
}
调用:// 二级指针必须这样
freeList(&sortedHead);

形参是 ListNode** headhead 存的是指针地址,要拿到原指针本身必须用 *head。 

4、传递解引用

形参:void func(ListNode node)

实参:func(*sortedHead);

效果:传节点副本,不能用来释放链表。

备注:结构体本身无法用-> , 用 node.val。 

查看代码
#include <iostream>
using namespace std;struct ListNode {int val;ListNode* next;ListNode(int x) : val(x), next(NULL) {}
};// 1. 指针传递(传地址副本)
void func_ptr(ListNode* p) {cout << "p 本身地址: " << &p << endl;cout << "p 指向的地址: " << p << endl;
}// 2. 值传递(传整个节点副本)
void func_val(ListNode n) {cout << "n 本身地址: " << &n << endl;//不写指向的地址,是因为根本没有 “指向的地址” 这个概念,n 是栈上结构体变量,不是指针,
}int main() {ListNode* head = new ListNode(10);cout << "head 本身地址: " << &head << endl;cout << "head 指向的堆地址: " << head << endl;cout << "\n--- 指针传递 ---" << endl;func_ptr(head);cout << "\n--- 值传递 (*head) ---" << endl;func_val(*head);
}
/*
root@VM-0-7-ubuntu:~/cpp_projects_2# ./sort
head 本身地址: 0x7ffec29ebb70
head 指向的堆地址: 0x559b8f69beb0--- 指针传递 ---
p 本身地址: 0x7ffec29ebb58
p 指向的地址: 0x559b8f69beb0--- 值传递 (*head) ---
n 本身地址: 0x7ffec29ebb50
root@VM-0-7-ubuntu:~/cpp_projects_2# 
*/

Q:普通指针和传递解引用一样啊?

A:首先传递解引用是值传递,因为函数形参是 ListNode 类型,不是指针也不是引用,你把 *head 传进去,编译器会拷贝整个节点结构体,生成一个临时副本。

传递指针由于传进去的是指针变量里地址的一份副本,不是原指针本身,也可以叫值传递。

且二者都是栈上的临时对象

唯一差别就是普通指针仍指向原堆内存,嗯可以delete

传递解引用传的是节点结构体副本,无指向概念,只能用 cout<< node.val << endl;这种功能。

Q:我代码里的

查看代码
ListNode* head = new ListNode(4);
head->next = new ListNode(2);
head->next->next = new ListNode(1);
head->next->next->next = new ListNode(3);

正常这么写?

A:面试刷题就这么写,也可以更加清爽:

查看代码
ListNode* createList(int arr[], int n) {if (n == 0) return nullptr;ListNode* head = new ListNode(arr[0]);// 建头节点ListNode* cur = head;// 用一个指针跟着走,不用一直写 head->next->nextfor (int i = 1; i < n; ++i) {// 循环批量加节点cur->next = new ListNode(arr[i]); // 核心:只写 cur->nextcur = cur->next; // 指针后移}return head;
}
int nums[] = {4,2,1,3};
ListNode* head = createList(nums, 4);

补充一个优先级极低的玩意,C++ STL双向链表容器list,主要考的是支持随机访问的vector(因为内存连续),链表内存不连续所以不支持随机访问,std::sort库是给随机访问用的,链表有自己的std::list::sort 底层就是归并排序,底层:每个节点带 prev/next 指针、内存不连续、无 [] 随机访问。优点:已知迭代器位置插入 / 删除 O (1)、迭代器稳定(删节点不影响其他迭代器)缺点:访问慢 O (n)、缓存不友好、现代服务端极少用。

查看代码
#include <iostream>
#include <list>
using namespace std;int main() {// 1. 创建list<int> lst = {4,2,1,3};cout << "创建后: ";for (int x : lst) cout << x << " "; cout << endl;// 2. 头尾增删lst.push_front(0);cout << "头插0后: ";for (int x : lst) cout << x << " "; cout << endl;lst.push_back(5);cout << "尾插5后: ";for (int x : lst) cout << x << " "; cout << endl;lst.pop_front();cout << "头删后: ";for (int x : lst) cout << x << " "; cout << endl;lst.pop_back();cout << "尾删后: ";for (int x : lst) cout << x << " "; cout << endl;cout<<"————————————"<<endl;// 3. 指定位置插入auto it = lst.begin();advance(it, 2);//list 没有 [],只能用它跳到指定位置,表示往后挪2步lst.insert(it, 9);cout << "插入9后: ";for (int x : lst) cout << x << " "; cout << endl;cout<<"@@@@@@@@@@@"<<endl;// 4. 删除lst.remove(2);//直接删掉容器里所有等于 val的元素。cout << "删除所有2后: ";for (int x : lst) cout << x << " "; cout << endl;lst.erase(it);//删除迭代器指向的元素。cout << "删除迭代器位置后: ";for (int x : lst) cout << x << " "; cout << endl;// 5. 排序lst.sort();cout << "排序后: ";for (int x : lst) cout << x << " "; cout << endl;
}/*
root@VM-0-7-ubuntu:~/cpp_projects_2# ./sort
创建后: 4 2 1 3 
头插0后: 0 4 2 1 3 
尾插5后: 0 4 2 1 3 5 
头删后: 4 2 1 3 5 
尾删后: 4 2 1 3 
————————————
插入9后: 4 2 9 1 3 
@@@@@@@@@@@
删除所有2后: 4 9 1 3 
删除迭代器位置后: 4 9 3 
排序后: 3 4 9 
root@VM-0-7-ubuntu:~/cpp_projects_2# 
*/

顺便补充个重要的deque支持随机访问:

查看代码
#include <iostream>
#include <deque>
using namespace std;int main() {deque<int> dq;// 增dq.push_back(1);dq.push_front(2);cout << "插入后: ";for (int num : dq) cout << num << " ";cout << endl;// 删dq.pop_back();cout << "删除后: ";for (int num : dq) cout << num << " ";cout<<endl;dq.pop_front();cout<<"删除后:";for (int num : dq) cout << num << " ";cout << endl;// 随机访问dq.push_back(10);dq.push_front(20);cout << "随机访问 dq[0] = " << dq[0] << endl;cout << "随机访问 dq[1] = " << dq[1] << endl;
}/*
root@VM-0-7-ubuntu:~/cpp_projects_2# ./sort
插入后: 2 1 
删除后: 2 
删除后:
随机访问 dq[0] = 20
随机访问 dq[1] = 10
root@VM-0-7-ubuntu:~/cpp_projects_2# 
*/

总结下:list仅仅是双向链表,用来做频繁在中间插入、删除元素的。然后他底层就是归并,但实际想用归并直接有用底层是归并的排序库函数std::stable_sort(稳定):

查看代码
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
struct ServerData {int id;int value;
};int main() {vector<ServerData> vec = {{1, 5}, {2, 3}, {3, 3}, {4, 1}};stable_sort(vec.begin(), vec.end(), [](const ServerData& a, const ServerData& b) {return a.value < b.value;});for (auto& s : vec) {cout << "id:" << s.id << " value:" << s.value << endl;}
}/*
root@VM-0-7-ubuntu:~/cpp_projects_2# ./sort
id:4 value:1
id:2 value:3
id:3 value:3
id:1 value:5
root@VM-0-7-ubuntu:~/cpp_projects_2# 
*/

且如果是list容器无法用std::sort必须用成员函数list.sort(),因为通用算法std::sort基于连续内存的支持随机访问的!

开始学单链表归并(非递归版本):

注释是边学边加的,但说实话加注释阅读相当费劲,很干扰,初学最好去掉注释,单独把注释放一遍对照着看,带着绿色注释,夹杂代码和汉字真的看的很难受

查看代码
#include <iostream>
using namespace std;
struct ListNode {int val;ListNode *next;ListNode(int x) : val(x), next(NULL) {}
};
ListNode* merge(ListNode* l1, ListNode* l2) {ListNode dummy(0);ListNode* cur = &dummy;while (l1 && l2) {if (l1->val < l2->val) {cur->next = l1;l1 = l1->next;} else {cur->next = l2;l2 = l2->next;}cur = cur->next;}cur->next = l1 ? l1 : l2;// l1 非空→取 l1;l1 空→自动取 l2,不用单独判 l2,比如 4->null,2->null,通过merger后是2->4->nullreturn dummy.next;
}
ListNode* sortList(ListNode* head) {if (!head || !head->next) return head;//边界判断,没节点 / 只有一个节点,不用排序,这是所有链表题的通用开头,即传入的是空或者单个节点直接返回int len = 0; ListNode* cur = head;while (cur) {// 求链表总长度 len,非递归必须知道链表多长,才能控制 “每次合并多长的段”len++;cur = cur->next;}ListNode dummy(0);//建虚拟头结点,因为链表头会在合并中不断变化,用 dummy 牢牢抓住最终的链表头,最后返回 dummy.next 就是答案。语法上这行是创建对象,dummy就是对象名字dummy.next = head;for (int step = 1; step < len; step <<= 1) {ListNode* pre = &dummy;// dummy 是栈上对象,取地址用 &dummy 得到指针ListNode* start = dummy.next;// dummy.next 本身已经是指针,指向的是真正原链表第一个节点while (start) {// 切左链 [start, right)ListNode* left = start;ListNode* right = left;for (int i = 0; i < step && right; ++i)//i < step表示最多走step步长,&& right表示走到空节点就停right = right->next;//这段起初我没理解,怎么左右都重合了,还左闭右开呢?后来发现就是一步步走的意思,且还没开始走呢,// left = start:左链的开头// right = left:让 right 从起点出发,准备往前走,for 循环走 step 步 right 向前走 step 次// 比如链表 4->2->1->3,这第一次就是搞出了4这个东西,即 [4,2)// 切右链 [right, nextStart),并断链ListNode* nextStart = right;for (int i = 0; i < step && nextStart; ++i)nextStart = nextStart->next;//即 [2,1)// 关键:断开左右两段ListNode *tail1 = left;// left = 左边那一段链表的头,tail1 用来找左边那段的最后一个节点ListNode *tail2 = right;// right = 右边那一段链表的头,tail2 用来找右边那段的最后一个节点            while (tail1 && tail1->next != right) //这里的 tail1 完全多余,tail1 不可能为NULL,因为能执行到这行,start 一定非空。我就直接没改加上了。如果是null->next是未定义,因为->本质上就是指针解引用 + 成员访问的简写,p->next 等价于 (*p).next。tail1 = tail1->next;tail1->next = NULL;// 找到左边那段的尾巴,然后把尾巴指向空while (tail2 && tail2->next != nextStart) //tail2&& 依旧多余tail2 = tail2->next;tail2->next = NULL;ListNode* merged = merge(left, right);pre->next = merged;//把前面的链尾接上合并好的新段。while (pre->next) pre = pre->next;//把 pre 移到新合并段的尾节点,方便下次接start = nextStart;//处理下一组}}return dummy.next;
}
void print(ListNode* head) {while (head) {cout << head->val << " ";head = head->next;}cout << endl;
}
ListNode* build(int arr[], int n) {if (!n) return NULL;ListNode* head = new ListNode(arr[0]);//head 是指针,指向这个刚创建的节点。节点里 val = arr[0],next = NULL。 而且解引用是节点本身(*head).val,也可以直接head->valListNode* cur = head;//多一个新指针,指向上面节点for (int i = 1; i < n; ++i) {cur->next = new ListNode(arr[i]);cur = cur->next;}return head;
}
int main() {int a[] = {4,2,1,3};ListNode* h1 = build(a,4);cout << "排序前:"; print(h1);h1 = sortList(h1);cout << "排序后:"; print(h1);
}

解释代码:

step <<= 1表示往左移 1 位,最右边补 0 表示数值 × 2:

数字 1   二进制:0001
左移1位:0010   → 等于 2 (×2)数字 2   二进制:0010
左移1位:0100   → 等于 4 (×2)数字 4   二进制:0100
左移1位:1000   → 等于 8 (×2)

新认知

妈逼的搞不清自己定位了,之前隐约知道一直学的linuxC++服务端开发貌似也分普通业务岗,今天看此文搜“生设计,比如内”的时候,问需不需要学list_head引发的新认知属于高性能 / 高并发 / 基础架构方向,必须称为【Linux C++ 高性能服务端开发】

很多东西是自己觉得没啥玩意(其实考的就这么浅)感觉干不了啥才一直深钻,再加上一直觉得不懂透彻根本没法干活,加上钻研强迫症(幸好有这种高门槛岗位否则就是死路),再加上豆包总误人子弟,80%都是错误,耽误我时间人工手动训练纠正,且总说某某某必须会其实真正要求是掌握即可,但我信豆包,救命稻草,导致超纲了,我直接可以手写大厂库(感觉很舒服,把不会的基础语法融会贯通),但已经超纲了,就像参加武林大会新人比赛,人家都是三脚猫,结果豆包说必须学会降龙十八掌达到乔峰的级别,但也歪打正着弥补了我极差的履历

这段代码真的理解起来痛苦至极!!非递归+单链表的归并,真他妈傻逼!!~~~~(>_<)~~~~

最快速就是硬头皮硬读,依靠豆包等大模型就是永无止境的被死全家的大模型误人子弟!无穷无尽的砸时间都没任何效果!

这里关于指针茅色顿开之前一直很乱,build函数里的操作,image,即

  • 初始:cur = head → 节点44->next = NULL

  • 执行 cur->next = new ListNode(2)4->nextNULL变为指向节点2

  • 节点2内部:val=2next=NULL

之前一直没搞懂这个head既然是指针为啥还有值和next指针,现在懂了。

然后说下如果不断链会咋样?豆包说不断链会死循环,但实际思考发现根本不会死循环,而是会排序成,

左链本该是4->NULL,右链本该是2->NULL,不截断直接是

左链:4->2->1->3->NULL

右链:2->1->3->NULL

进入merge直接给搞成:2->1->3->4->2->1->3->NULL ,因为右链所有都比左链起手的 4 小,哎,习以为常了毕竟大模型是个傻逼。

代码里的找到左边那段的尾巴,然后把尾巴指向空那段我误以为可以写成right->next=NULL,发现不行,会使得右侧链断裂。 

插一嘴:

我突然好奇为啥归并要每次除以 2,非递归的代码里就是步长为啥每次 * 2,每次搞的是 3 不行吗?豆包说都行没任何问题,只不过搞 3 就要三路归并直接加大代码难度,直接变成多路排序 —— 外部排序了(但其实不是,详见下)!

然后我想去回顾外部排序代码(此文搜“先给外部排序的代码:”),想看看是递归还是非递归,突然发现不涉及递归与否的问题,只是用的宏观的归并思想,即先sort库函数(这里其实可以是手写归并),然后优先队列(底层为二叉堆),就是手写的二路归并里合并比较merge函数,即外部排序 = 分块 + 内部排序 + 多路归并。

k 路归并的标准实现就是最小堆,堆承担了 “每次从 k 路里选最小” 的归并逻辑,堆就是归并的高效实现。

我一直以为“多路排序 = 外部排序;普通归并是 2 路排序”,但豆包说不对:

  • 多路归并是算法思想,可在内存也可在外存使用。

  • 外部排序是场景:数据大于内存,必须磁盘 IO。

  • 内存里也能做 3 路、4 路归并排序,那不叫外部排序。只是二路更好写,

  • 二路归并 ≠ 多路归并,多路归并是 k≥2 的统称。

“宏观归并思路 + 堆找最小值 = 外部排序”

此时也搞清了归并、多路归并、外部排序,二路归并都是什么 JB 玩意,其实很多都叫的不规范。

更加透彻理解归并。

狗逼死全家的豆包在这里完全就不可靠!!这里的无数思想豆包全是错的,我说出我的想法后,豆包又开始无尽道歉无尽否定自己了。

继续看代码:

Q: ListNode* pre = &dummy; ,这里pre是指针,指向的节点是dummydummy里的值是0,那pre->next是啥?他是一个指针,为啥还有next这个成员?!

A:pre是指向结构体 ListNode 的指针,->的作用就是让指针直接访问它所指向结构体的成员。pre->next就是 pre 指向的那个节点里的 next 成员,也就是这个节点的下一个节点的地址。

Q:声明 = 告诉编译器 “有这个变量”;定义 = 给变量真正分配内存。这里咋每次都是新定义+声明各种指针变量?不会重复定义吗?

A:递归里搞个int a,进入下层递归是新开,返回直到出了本次递归参失效销毁,for、while也类似,出了本次循环,直接销毁本次的变量啥的。

然后看此手稿图,image,我发现单链表其实就是原始搞的初始数据链,然后一次次的排序(每次都是废除旧链,比如第一次是排序 4 和 2,排好的 2、4 衔接后面的 1、3 这个原始数据链上,无关乎 1、3 是否有序),逐步废旧链(不是删除节点、也不是释放内存,只是把原来的 next 指针改掉,旧的链接关系失效,换成新的链接),搞新链衔接,感觉内存里的效果就是一堆原始数据节点,然后通过链来逐渐更新?迭代?,这里我突然想到了个词汇,最短路里的 —— 松弛(整体越来越 “规整、松弛” 最后完全有序),每次向最优解靠拢,我又想到了贪心?DP?分治?

  • 贪心?但不太像,由于没时间刷贪心,只是大学算法比赛心浮气躁的学过,大概就是说,每一步做出局部最优选择,且这个选择永远不会被推翻、不会再改。但这里每次的位置不一定是最终位置,后面还会有新节点插进来,把前面排好的结构再次改动,之前的 “局部最优” 会被修正,不是贪心。

  • 分治?对头!非递归归并 = 自底向上的分治,一开始每个节点是一个有序小区间,两两合并成更大的有序区间,不断翻倍 size:1→2→4→8→…,直到整个链表有序。这就是分治法的迭代实现,没有递归栈,但思想依然是分治。

  • DP?豆包说DP 要有:最优子结构 + 重叠子问题 + 状态转移。归并排序只是划分合并,不记录、不复用子问题最优解,不属于 DP。

这种串联面试面试官喜欢吗 

回忆:之前学啥来着,想到了其实就是算法的 离散化

又问了下: 原来二哥不是高性能

现在唯一需要考虑的是while (tail2 && tail2->next != nextStart)这里的tail2有无必要。

其实这个问题引发我另一个对代码的深刻认知,即我举了一个例子4 2 1

这里先step是1,4 2搞成2 4,成2 4 1,然后此文搜“合并,没有”可知,那里是数组版本非递归,该判断下一组了,也就是 1 和某,但只剩下 1 了,所以没有与 1 匹配的右段还比啥?还有啥可交换的?就  1 自己,所以右段直接就跳过了,但单链表非递归这里没跳过,很重要的一点是,之前是数组,传递引用后,你每次修改或者不修改,原数据在那,修改后结果永远是更接近最终结果,而这里要知道4 2完事后,pre->next = merged;是把前面的链尾接上合并好的新段,此时pre指向的数字 2,start指向的是数字 1 因为要准备处理下一组,如果跳过你新链不只剩下4 2了吗,所以不能跳,必须进循环处理,就是为了把剩下的节点原封不动接上,然后成为2 4 1,下次循环step=2,直接2 41搞排序成1 2 4

总结:

  • 之前数组版本的非递归归并,是提前判断“有没有右链”,没有就直接跳出不处理;

  • 而这单链表版本非递归归并,不提前跳,是把不完整/空的右链带进循环,靠 merge(左, null) 直接返回左来接上

而上面的单链表递归版本,递归会一直拆分,直到每个小组都只剩 1 个节点,1 个节点本身就是有序的,两两合并就行,不存在没有右链、要不要跳过这种情况。而非递归是从底向上批量处理,才需要考虑剩一个节点要不要进循环。

好,这个无意间发现的、很重要的插曲说完继续考虑tail2是否有必要判断非空?tail1没必要这件事是我自己想的,豆包只会误人子弟

我思考的tail1是(关于tail1,他能运行到while (tail1 && tail1->next != right)必然不是NULL,空或者只有一个节点在起手if (!head || !head->next) return head;return了,然后每次tail1 = tail1->next;一定会被tail1->next != right拦截住,那如果tail1NULL咋办?如果tail1NULLtail1->next还不是right,这种情况不可能啊,如果是NULL,在for (int i = 0; i < step && right; ++i)right = right->next;一路扫的时候就已经遇到了)。

然后思考tail1,举两个例子,两个和三个的就像(离散化)HDOJ11页那个上楼梯递推,所有的最后一定可以用 2 or 3 表示,我用例子4 2 1发现,搞成2 4 1,然后step=2,是左链[2,1),右链[1,null),这里tail2是被right赋值了,right是右链起点,然后右链尾巴nextStart,中间不可能有NULL,除非nextStart本身是NULL,这是单巴棱的一个right直接挂上,而如果连单巴棱都没有不就right踩空搞成NULL了吗?非也!要么像只有 4 个的,step是 2,搞完两组后while(start)直接NULL退出,要么只有 2 个的,step是 2 直接进都不会进去,所以tail2没必要,那再看两个的,我举例子就是2 1[1,2)[2,NULL),下轮不符合step < len直接退出了,因为左右段的时候必须接上,而这里是不存在接的问题,因为只有1 2搞好了,你想来一轮step = 2必须1 2右侧还有数据。  

插一句科普:

  • NULL 不是指针,它是表示空指针值的常量,C++ 中等价于宏定义了的整数常量0,用于给指针赋值表示不指向任何对象。不指向任何合法对象,对应地址值是常量0,但这个地址不允许访问(有些编译器估计可以我的VScode远程控制腾讯云就返回了 0)

  • tail2->next 是指针类型,cout时结果是它存储的地址值。为NULL时我的编译器输出 0 尽管未定义。

  • tail2是内存地址,*tail2编译语法不允许cout因为*tail2是结构体

  • tail2如果是 2,然后链表是2->null,那tail2->next合法,不合法的是对nullnext

综上:tail1tail2的判空都没必要(这种问题上,豆包只会误人子弟 + 马后炮!!)

至此精通单链表非递归版本的归并!!

发现链表指针题目不过如此啊,套路挺强的,之前还说比最短路最小生成树那些难呢,但那些题目是难在场景不好结合算法,算法好研究懂,而指针链表这些算法是起手的模板、边界各种细节不好理解(只是我想的多,极致精通的地步),但模板研究懂后直接乱杀,没啥场景。

刷多了人容易傻,之前算法好像搜索?还是啥?也是如此(就是“这道题读不懂,但AC还是没有问题的”)

但是心如死灰,死全家的豆包刚说字节、腾讯、百度必考单链表非递归归并,花了 3 天啃精通,再问又说100%不会考,哎!习惯了

链表不能随机访问 → 不能用快排、堆排 → 只能用归并排序。

哎学就学了崩溃也无济于事,问问豆包浪费掉 3 天的时间啃精通(而不是傻逼应试背代码)单链表非递归归并咋能 利益最大化 吧!~~~~(>_<)~~~~

3.13.4、C++ 原生 STL 神器nth_element

查看代码
#include <iostream>
#include <vector>
#include <algorithm>//包含 nth_element
using namespace std;int main() {vector<int> v = {3, 1, 4, 1, 5, 9, 2, 6};// 第3小,下标2nth_element(v.begin(), v.begin() + 2, v.end());cout << v[2] << endl;// 第2大nth_element(v.begin(), v.begin() + 1, v.end(), greater<int>());cout << v[1] << endl;
}
// 1 1 2 3 4 5 6 9

参数:nth_element(起始迭代器,第n个位置迭代器,结束迭代器比较函数

  • arr.begin():数组起点

  • arr.begin() + 2:定位第 3 小元素(下标 0 开始)

  • arr.end():数组终点

  • greater<int>():从大到小,用于找第 K 大

效果:左边 ≤ 它,右边 ≥ 它,不保证整体有序,不稳定,复杂度 O(n),前面证明过。

服了这个狗逼,起初说nth_element不考,我略过了,又说要考,但只靠一行库,不考手写,现在又说必须手写!(学完又说不考,哎!!!!!真的痛苦死了!)

之前说单链表非递归归并大厂必考手写,我啃精通后又说完全不考,现在再次问又说考!!说就算我写面试官都懒得看

狗逼豆包害人不浅可我别无他法!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

猛然发现一件事百度直接搜的百度 AI 都比豆包靠谱

死全家的豆包说,写进简历抵消我极差的履历,狗逼玩意,我他妈是要把全宇宙的知识点都学完来抵消吗!!!!~~~~(>_<)~~~~

终极总结链表、数组

链接里最后那个问题“因为你找的某K必须就是某!!”死全家的傻逼豆包也在完全误人子弟,三数取中随便用,和第 K 大毫无任何关系。

大概意思就是nth_element递归需要会但不考,非递归必须会且必考因为简单可以快速写出,考察效率高,

而单链表非递归归并几十分钟都写不完,无考察效率。

归并只考单链表递归,数组非递归

更多底层原生用啥?考的是啥?学精通有啥用,怎么换个说法

死全家的豆包永无止境的反反复复无穷无尽的误人子弟,反复矛盾,关于单链表非递归归并,关于非递归nth,

1点问说不需要会

2点问说必须会

3点问说不需要会

4点问说必须会

5点问说不需要会

6点问说必须会...

死循环的误人子弟,把两个矛盾一起发,又会道歉+找各种很合理的借口说误以为是工程上的实践,工程库用非递归,面试手撕算法是递归,不需要会非递归。混淆了工程VS手撕。

误打误撞

哎学吧!学个手写nth_element的递归版本找找感觉:

查看代码
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;void nth_element_recursive(vector<int>& v, int n, bool is_asc) {// is_asc:是否升序asc = ascendingif (v.size() <= 1) return;int pivot = v[v.size() / 2];vector<int> left, mid, right;for (int x : v) {if (is_asc) {if (x < pivot) left.push_back(x);else if (x == pivot) mid.push_back(x);else right.push_back(x);}else{if (x > pivot) left.push_back(x);else if (x == pivot) mid.push_back(x);else right.push_back(x);}}cout<<"#";for (int x : left) cout<<x<<" ";cout<<"@"<<endl;cout<<"#";for (int x : mid) cout<<x<<" ";cout<<"@"<<endl;cout<<"#";for (int x : right) cout<<x<<" ";cout<<"@"<<endl;cout<<endl;if (n < left.size()) {//下标从0开始,第4小就是找下标3小,left.size是3 1 3 4这四个数显然属于这堆里的,如果找4小,明显不应该进堆nth_element_recursive(left, n, is_asc);v = left;//相当于把递归回来的1 3 3 4给了原数组v,也就是覆盖成这个1 3 3 4v.insert(v.end(), mid.begin(), mid.end());v.insert(v.end(), right.begin(), right.end());} else if (n < left.size() + mid.size()) {//不能等于v.clear();v.insert(v.end(), left.begin(), left.end());v.insert(v.end(), mid.begin(), mid.end());v.insert(v.end(), right.begin(), right.end());} else {//就是等于left.size() + mid.size() 到 left.size() + mid.size() + right.size()nth_element_recursive(right, n - left.size() - mid.size(), is_asc);v.clear();v.insert(v.end(), left.begin(), left.end());//等价于从头开始放v.insert(v.end(), mid.begin(), mid.end());v.insert(v.end(), right.begin(), right.end());cout<<"!";for (int x : v) cout<<x<<" ";cout<<"!"<<endl;}
}int main() {vector<int> arr = {3,1,9,3,7,4,20,10};  // 1 3 3 4 7 9 10 20int k = 3;// 下标从 0 开始, 想找第 4 小应该 k = 3nth_element_recursive(arr, k, true);// nth_element_recursive(arr, k, false);   // ture  找第K小// false 找第K大// nth_element 是第 n 个元素选择(C++ STL 算法名),指找到序列中第 n 小或第 n 大的元素. recursive 递归的cout << arr[k] << endl;
}/*
root@VM-0-7-ubuntu:~/cpp_projects_2# ./sort
#3 1 3 4 @
#7 @
#9 20 10 @#1 @
#3 3 @
#4 @!1 3 3 4 !
4
root@VM-0-7-ubuntu:~/cpp_projects_2# 
*/

代码思路:

{3,1,9,3,7,4,20,10}想找第 4 小,进入nth_element_recursive,称为递归初始 1 层,基准选择中间位置的元素,这里是 7(之前快排选的是三数取中,这里只是中间位置,不正规,不属于优化,正确的应该取中来避免退化为最坏复杂度),起手就分三堆:>7<7==7,即:

#3 1 3 4 @

#7 @

#9 20 10 @

找第 4 小,直接进if (n < left.size()) {,然后只把#3 1 3 4 @搞进nth_element_recursive,称为递归 2 层,然后依旧是分堆:

#1 @

#3 3 @

#4 @

然后找第 4 小直接进入最后那个else,进入nth_element_recursive(right, n - left.size() - mid.size(), is_asc);称为递归 3 层,遇到if (v.size() <= 1) return;直接返回此 2 层,然后v.clear();v指的是函数里第一个参数的引用,所以是上一层传递过来的那组#3 1 3 4 @,清空了,但没事,数据保存在了自己这一层栈的left、mid、right里,直接拼接为!1 3 3 4 !,然后该返回到递归 1 层,执行v = left;,此时vmain里传过来的原数据{3,1,9,3,7,4,20,10}left是引用传递过去的但已经变为!1 3 3 4 !,直接v.insert(v.end(), mid.begin(), mid.end());v.insert(v.end(), right.begin(), right.end());

#7 @

#9 20 10 @

原封不动的接上,因为不需要知道他们的顺序,已经找到了想要的第 K 小。

大体流程懂了,思考些细节:

关于几个if-else

第一组if (n < left.size())懂了,后面原样接上,像单链表的思路(离散化)

第二组else if (n < left.size() + mid.size())很简单就是clear然后拼接,大于小于的无所谓,这堆是中间的,例子:{3,1,9,3,7,7,4,20,10};  // 1 3 3 4 7 7 9 10 20

但注意衍生一个问题 —— 去重(后面说)。

第三组else {,起初我好奇这里为啥不像第一组一样的逻辑,拼接leftmid,然后把递归搞回来的有序的right接上去,自己思考 1min 搞定,死全家的豆包追问 10min 反复误人子弟又辱骂了 8min,我自己拿例子{3,1,5,4,2,6,9,8,7};int k = 7;跑一遍,结果是:

查看代码
root@VM-0-7-ubuntu:~/cpp_projects_2# ./sort
#1 @
#2 @
#3 5 4 6 9 8 7 @*
#3 5 4 @
#6 @
#9 8 7 @*
#7 @
#8 @
#9 @!3 5 4 6 7 8 9 !
!1 2 3 5 4 6 7 8 9 !
8
root@VM-0-7-ubuntu:~/cpp_projects_2# 

直接发现,第一组和第三组里v = left;v.insert(v.end(), mid.begin(), mid.end());v.insert(v.end(), right.begin(), right.end());v.clear();v.insert(v.end(), left.begin(), left.end());v.insert(v.end(), mid.begin(), mid.end());v.insert(v.end(), right.begin(), right.end());本质就完全等价!搞的老子还以为有什么特别的东西!

这里根本不是之前标准快排的双指针交换,它是【左右分数组搬运】,无脑简单,所以没坑!全程没有指针碰撞、没有边界判断、没有交换顺序问题:

标准快排:
  • 原地交换

  • 左右指针往中间跑

  • 要判断 i < j

  • 要控制先移动左还是先移动右

  • 要处理等于、越界、碰撞

现在写法 = 暴力分组合并 → 逻辑简单,不踩坑、标准快排 = 原地交换 → 指针乱跳,全是边界细节

原地双指针快排大厂笔试、面试唯一认可,必考。原地、低空间、符合工程标准。

当前临时数组分割写法面试不认可。额外开容器、空间开销大,属于简化玩具写法,工程里不会用。

 

且此处扩展出,快排也可以三路排序,大大降低重复比较,但开临时依据不提倡,是用三个指针,三个边界维护,大量边界条件和细节说毫无意义及时止损!

开始说去重(暴力):

狗逼豆包不出意外依旧是在误人子弟!但给了我启发!

这里我想到了离散化思想:

之前 刷算法题 提到过,搞笑的是我居然看不懂了,于是问豆包帮我看,回忆起两个离散化:一个是说西安算法培训一样的,线段树那种,比如只有 10000 这一个数据,那就要开 10000 大小的数组,而可以直接用离散化,另一种就是博客里那个不需要全都初始化并查集。

这里给出几个东西,然后引出大厂考的必须会的很重要的东西(学懂后面试官随便问,学一桶水面一杯水的感觉):

查看代码
//【去重后元素个数】即不同数字的个数,注意比如 1 3 3 总共应该是 1 3 两个数,而不是只找非重复的那个 1
int count_unique(vector<int>& a) {int cnt = 0;int n = a.size();vector<bool> visited(n, false);  // 标记是否已经算过,语法:给n个数据都初始化为falsefor (int i = 0; i < n; ++i) {if (visited[i]) continue;bool is_first = true;// 往前找有没有出现过for (int j = 0; j < i; ++j) {if (a[j] == a[i]) {is_first = false;break;}}if (is_first) {cnt++;// 把后面所有和 a[i] 相等的都标记for (int k = i; k < n; ++k) {if (a[k] == a[i])visited[k] = true;}}}return cnt;
}
vector<int> a = {1, 3, 3, 2, 2, 2, 5};

这个去重,只是用break简单的做了剪枝,然后远不是离散化,只能称作暴力去重。

起初我看比如第一个数字是 1000,并没有visited[1000],完美节省了空间,误以为这就是离散化,但不是,ACM 里离散化定义时候建立「原始数值 → 连续小整数」的映射关系,工程没离散化这个词汇,visited 数组标记的是元素在原数组中的位置下标,不是原数值的映射下标,全程没有对原数值做任何映射转换,只是在标记原数组位置是否重复,本质是基于位置的暴力去重。

就算原数值是1000000000,你代码里也没把这个数映射成 0/1/2 这类小下标,只是标记原数组第 0 位被访问过,和离散化的核心逻辑完全无关。

离散化的关键是数值映射,不是单纯 “少开数组空间”,你只抓住了 “省空间” 的表象,忽略了最核心的数值→下标映射,所以严格来说这不是离散化,单纯每个位置搞了个visit数组。

 离散化是:

  • 数字超级大:100000、1000000004

  • 不能开 a[1000000004] 这么大的数组

  • 所以把大数变成小下标

    • 100000 → 0

    • 1000000004 → 1

  • 然后只用 vis[0]vis[1] 标记

傻逼豆包在专业知识上依旧跟用户情绪无脑符合。每天的出错率达到 90%,要花费大量的时间人工手动的追问、质疑、纠错。

我真的不知道能用大模型干的东西究竟有多傻逼多低级多弱智多不需要思考能力,唉!!!豆包、GPT、Deepseek、通义千问、文心一言全部如此

改进(真正的离散化 —— 关键就是有了映射):

原理可以此文搜“起两个离散化”那链接里的去回忆下(俩数组 、西安算法竞赛艾教的培训课 ACM 种树问题):

查看代码
#include <iostream>
#include <vector>
#include<cstring>
using namespace std;
int find_id(int val[], int n, int x) {// 遍历找 x 对应的小下标 0、1、2...for (int i = 0; i < n; i++) {if (val[i] == x) return i;}return -1;
}int val[100];  //第一个元素是1000,不写 val[1000] 而是直接放到 val[0] = 1000
int vis[100];  
int count_unique(vector<int>& a) {memset(vis, 0, sizeof vis);int cnt_val = 0; // 不同数字的个数for (int x : a) {int id = find_id(val, cnt_val, x);if (id == -1) {val[cnt_val] = x;vis[cnt_val] = 1;cnt_val++;}}cout<<"#"<<cnt_val<<endl;return cnt_val;
}int main(){vector<int> a = {1, 3, 3, 2, 2, 2, 5};cout<<count_unique(a)<<endl;
}

有些差别是,这里vis没用到,只要搞到了val数组里,就都是独一无二的数字,不再需要vis了,而那个豆包链接里是说,先把稀疏的几个点存进val数组然后再由vis更新状态决定是否种树啥的。

插一句:if ()0 假,非 0 数字(包括 - 1)真。

去重库:

查看代码
#include <iostream>
#include <vector>
#include <map>
using namespace std;int count_unique_map(vector<int>& a) {map<int, bool> mp;//默认值是 falsefor (int x : a) {mp[x] = true;  // 重复会覆盖,不影响结果}for(auto x:mp)cout<<x.first<<" ";cout<<endl;return mp.size();
}
int main() {vector<int> a = {1, 3, 3, 2, 7, 4, 5};cout << count_unique_map(a) << endl;
}

map<int, bool> 记录是否出现过,天然去重,是离散化思想:把大数值映射成小序号,不是开 x 大小的数组,mp[x]是往map里插入一个键值对:键是 x,值是 true,只占一个位置,重复 x 会覆盖,不新增。

注意:

  • cout 不能直接输出 vector,必须循环打印!

  • map 里的 key 永远自带 const 修饰,否则红黑树排序结构直接崩溃
  • map<int, int>的每个元素就是pair<const int, int>。 

去重库(增加离散化思想但毫无不同):

查看代码
#include <iostream>
#include <vector>
#include <map>
using namespace std;// 离散化:把数值映射成 0,1,2...
vector<int> discretize(vector<int>& a) {map<int, int> mp;for (int x : a) mp[x];  // 插入所有 key,自动排序去重,等价于mp[x] = 0; 默认int:0cout<<"size: "<<mp.size()<<endl;;int idx = 0;for (auto& p : mp) // auto 推导出来是:pair<const int, int>&p.second = idx++;for (auto& p : mp) cout<<p.first<<" "<<p.second<<endl;cout<<endl;vector<int> res;for (int x : a) {res.push_back(mp[x]);cout<<mp[x]<<" ";}cout<<endl;return res;
}int main() {vector<int> a = {1, 3, 3, 9, 7, 4, 5};vector<int> ans = discretize(a);for (int x : ans) cout << x << " ";cout<<endl;
}
/*
原始数字 1 → 编号 0
原始数字 2 → 编号 1
原始数字 3 → 编号 2
原始数字 4 → 编号 3
原始数字 5 → 编号 4
原始数字 7 → 编号 5
——————————
原数组:1 → 查表 → 0 → 压入 res
原数组:3 → 查表 → 2 → 压入 res
原数组:3 → 查表 → 2 → 压入 res
原数组:2 → 查表 → 1 → 压入 res
原数组:7 → 查表 → 5 → 压入 res
原数组:4 → 查表 → 3 → 压入 res
原数组:5 → 查表 → 4 → 压入 res
*/

Q:mp[x] = 0; 默认int:0不就没意义了吗!!

A:这行本身赋值 0 确实没意义!这行唯一目的就是强行把 x 插入 map,只要能插入 key,值是 0 完全没用,后面马上用 idx 覆盖掉!

Q:指针还是点

A:范围forfor (auto& p : mp)里 p 是引用,直接用 . → p.first、p.second。

迭代器指针map<int,int>::iterator it = mp.begin();,用 -> → it->first、it->second。

注意:去重unordered_map 和map完全等价,只不过map多个排序,豆包说离散化就必须map(需要一对一映射 + 排序)

unique独一无二的。

这里插几个东西:海厚海厚!!

二叉树就像一个倒着长的树:

  • 一个节点最多俩孩子

  • 左边叫左孩子,右边叫右孩子

二叉搜索树 BST 只多了一条死规矩

对任意一个节点:
  • 左子树里所有值 < 自己

  • 右子树里所有值 > 自己

比如插入顺序:5 → 3 → 7 → 2 → 4 → 6 → 8,结构长成这样:

        5/   \3     7/ \   / \2   4 6   8

任何节点左边都更小、任何节点右边都更大,这就是二叉搜索树。能干啥?查找数字就像查字典,想找 4:

  1. 看根 5,4 比 5 小 → 往左走

  2. 看 3,4 比 3 大 → 往右走

  3. 找到 4

每一步都能砍掉一半可能性,所以速度是 O(log n)。

但它有致命问题,如果插入已经排好序的数:1 → 2 → 3 → 4 → 5 → 6 → 7,树会变成一条直线:

1\2\3\4\5\6\7

这时候查找就变成从头到尾遍历,速度退化成 O(n) 的链表,跟链表一样慢。

所以才有了红黑树,红黑树 = 带自动平衡功能的二叉搜索树,旋转节点自动调整结构,不让树歪掉,始终保持平衡(而普通二叉搜索树完全不会自动调整,只会老老实实按插入顺序长,长歪成链表也不管),它不会变成一条直线,永远保持矮胖,保证任何时候都是 O (log n),C++ map 底层就是这个东西。

然后说哈希:

先想一个最简单的东西:数组

a[0]
a[1]
a[2]
a[3]
...
想找 a[123],直接一步找到,超级快。但问题来了:
  • 如果数字是 123456789,你开不出这么大的数组

  • 如果 key 是字符串 "apple",根本不能当下标

  • 如果 key 是乱七八糟的 ID,数组根本用不了

这就是数组的死穴。

哈希函数 = 一个万能转换器,不管你给它超大数字、字符串、结构体、指针,它都能给你转成一个小小的数组下标:

hash(123456789)  =  5
hash("apple")    =  3
hash(0x7fff1234) =  9

这样,再大的东西,都能塞进一个小数组里。这就是哈希的本质。

unordered_map 是什么?就是 C++ 帮你写好的:“哈希函数 + 数组” 的成品:unordered_map<Key类型, Value类型> 变量名;,它内部自动:

  • 把 key 转成下标

  • 存在数组里

  • 你要查的时候再转一遍下标,直接找到 

什么叫哈希冲突?哈希函数是取模有时候会出现两个完全不一样的 key,哈希函数算出来同一个下标:

hash(123) = 2
hash(456) = 2
这就叫冲突。怎么办?C++ unordered_map 用最简单的办法:同一个下标位置拉一条链表,结构变成这样:
数组[0]
数组[1]
数组[2] → 123 → 456  ← 挂一条链表
数组[3]

查找时:

  1. 先算下标找到数组位置

  2. 再在小链表里遍历找 key

    因为链表很短,所以还是极快。 

实操抽插: 

查看代码
#include <iostream>
#include <vector>
#include <list>
using namespace std;// 最简单哈希表:数组 + 链表
struct HashTable {// 一个数组,每个位置挂一条链表vector<list<int>> table;int cap; // 数组大小HashTable(int capacity) {cap = capacity;table.resize(cap);}// 插入void insert(int key) {int idx = key % cap;//哈希函数 = 取模table[idx].push_back(key);}// 查找bool find(int key) {int idx = key % cap;// 冲突就遍历链表找for (int x : table[idx]) {if (x == key) return true;}return false;}
};int main() {HashTable ht(7); // 创建一个哈希表,名字叫 ht,数组大小7ht.insert(123);ht.insert(456);ht.insert(123);cout << ht.find(123) << endl; // 1cout << ht.find(789) << endl; // 0
}

C++ 里struct 和 class 几乎一样,结构体完全可以有构造函数并被调用。

main 里执行 HashTable ht(7);,调用构造函数 HashTable(int capacity),执行 cap = 7;,执行 table.resize(7);vector<list<int>> table初始化为 7 个空链表对应:[0]~[6] 都是空链表。

resize 是 vector 的库函数,vector 装的就是链表,就开 7 个空间,每个空间是空链表。ht.insert(123);123%7=4table[4] 链表里塞 123,

[0] → 空链表
[1] → 456
[2] → 空链表
[3] → 空链表
[4] → 123 → 123
[5] → 空链表
[6] → 空链表

如果是插入“abc”也是算 ASC 然后搞模,find判断是否存在,也可以输出位置。

遍历会逐个检查,两个 123 都能匹配到,只是找到第一个就直接返回 true 了。

术语:

数组里每个下标位置就叫个桶,这里是哈希桶,数组每个元素挂一条链表。

哈希表 = 用哈希函数把数据映射到数组下标,实现快速查找的结构。这段代码就是最标准的哈希表!!是数组 + 链表的哈希表(拉链法),还有一种不考:

  • 拉链法(你写的这种:数组 + 链表)

  • 开放寻址法(用数组,冲突就往后找空位)

哈希函数就是取模(还有其他的方式不需要学)。 

unordered_map就是比我写的数组 + 链表的哈希表多一个红黑树的动态平衡但也仅仅是冲突且很长的时候,即比如 10 个数据开 10 个list数组,每个是 10 个空链表,但都取模到了 3 号位置,那查找就是退化为 O(N), 红黑树不用链表改用树,把同样位置的直接平衡成 logN。即本来是链表,只有冲突且链表很长才转红黑树。

哈希是一种思想:把一个东西 → 算出一个数字 → 当下标用。

哈希数组是说没冲突处理这件事,只是数组,而增加冲突处理也就是增加链表,就成了哈希表,即用哈希思想做出来的整体结构。

看个库函数使用:

查看代码
#include <iostream>
#include <unordered_map>  // 必须头文件
using namespace std;int main() {// 创建:key-value 键值对,key唯一unordered_map<int, string> umap;  // 插umap[1] = "张三";   //规则: 有这个 key → 覆盖原来的值。没有这个 key → 自动新增一个umap.insert({2, "李四"});// 以拷贝方式插入键值对,规则是:有这个 key → 不插入不覆盖。没有这个 key → 才插入umap.emplace(3, "王五");// 效率最高, 直接在容器内存原地构造元素,避免额外拷贝// count 只返回 0(没有) 或 1(有), 有就不插,没有才插 → 用 insertif (umap.count(1) == 0) umap[1] = "张阿飞三";      umap.insert({2, "李都是四"});// 遍历(两种常用)// 范围for(推荐)for(auto& pair : umap) cout << pair.first << " : " << pair.second << endl;cout<<endl;// 迭代器for(auto it = umap.begin(); it != umap.end(); ++it) cout << it->first << " : " << it->second << endl;cout<<endl;// 查auto it = umap.find(2);if(it != umap.end()) {cout << "找到:" << it->second << endl;}// 删umap.erase(1);  // 按key删umap.clear();   // 清空
}
  • umap[key]:取值 / 赋值,key 不存在会自动创建

  • insert({k,v}):插入,key 重复会失败

  • emplace(k,v):最优插入,推荐

  • find(k):返回迭代器,找不到返回end()

  • count(k):返回 1(存在)/0(不存在),快速判断存在

  • erase(k):删除指定 key

特性:

  • 底层:哈希表,无序、查询 / 插入 / 删除平均 O (1)

  • key 必须可哈希(int/string/ 指针等基础类型都支持)
  • 不允许 key 重复,重复插入会覆盖 / 失败,注意key重复 ≠ 哈希冲突,

    查看代码
    #include <iostream>
    #include <unordered_map>
    using namespace std;int main() {unordered_map<int, string> umap;// 看当前总桶数!!!自带函数!!cout << "总桶数:" << umap.bucket_count() << endl;// 插两个必然冲突的 keyumap[1] = "张三"; // key 取模放桶cout << "key为 1 的桶: " << umap.bucket(1) << endl;//放在了几号坑位(桶),编号从 0 开始cout << "总桶数:" << umap.bucket_count() << endl;umap[1 + umap.bucket_count()] = "冲突元素";// 1 加上任意桶数 n 后,模 n 的余数和 1 模 n 的余数数学上完全相等cout << "1的桶:" << umap.bucket(1) << endl;//放在了几号坑位(桶),编号从 0 开始cout << "总桶数:" << umap.bucket_count() << endl;cout << "冲突key的桶:" << umap.bucket(1 + umap.bucket_count()) << endl;cout << "桶里元素数:" << umap.bucket_size(umap.bucket(1)) << endl;
    }

map又是咋回事?

map全程就是纯红黑树,没有哈希数组、没有链表,从头到尾都是树结构:

查看代码
#include <map>
#include<iostream>
using namespace std;
int main(){map<int, string> mp;// 插mp[1] = "刷a";               // 常用,不存在则插入,存在则覆盖mp.insert({2, "b"});       // insert 不覆盖,已存在则插入失败// 查auto it = mp.find(1);      // 存在返回迭代器,指向 key = 1 那一组键值对的定位器,不存在返回 mp.end()if (it != mp.end())cout<<"找到:"<<it->second<<endl;// it 不是指针,但用法和指针一样,用 -> 访问成员cout<<"@"<<mp.at(1)<<"@"<<endl; // 通过 key 获取 value。如果 key 1 不存在,直接报错抛异常,不会插入新数据。安全:查不到就是查不到,不会偷偷改数据。cout<<"!"<<mp[1]<<"!"<<endl;   //慎用,通过 key 获取 value。如果 key 1 不存在,map 会自动把这个 key 插入进去,并给一个空的 value。风险:你只是想查一下,结果偷偷多了一条数据。cout<<"——————开始遍历——————"<<endl;for (auto& p : mp) {p.first;   // keyp.second;  // valuecout << p.first << " " << p.second << endl;}cout<<"——————结束遍历——————"<<endl;// 删mp.erase(1); // 按 key 直接删除传一个键值进去,map 找到这个键就删掉,找不到就什么都不做。安全,不会崩溃。// mp.erase(it);        // 按迭代器删除传一个迭代器进去,直接删除迭代器指向的元素。必须保证迭代器有效,迭代器失效 / 越界就崩溃。// 先 erase (key),再 erase (it) 必崩!因为 erase(1) 已经把元素删了,it 变成无效迭代器,再删直接程序崩溃。cout<<"大小: "<<mp.size()<<endl;cout<<"空?: "<<mp.empty()<<endl;// 判断 map 是不是空的// 空 → 返回 true// 有数据 → 返回 falsemp.clear();// 清空 map 里所有数据  
}

说几个事:

红黑树 = 二叉搜索树 + 强制平衡,往map里加数据必须是同类型,key可以是任何类型,结构体就:

查看代码
struct Node { int x; };
bool operator<(const Node& a, const Node& b) { return a.x < b.x; }
map<Node, int> mp;

然后实测发现,cout时,map会给你按照key排好序,unordered_map 就不会排序,是随机的给出的,因为map 底层是红黑树(有序平衡二叉树),插入时自动排序,而unordered_map 底层是哈希表,只算哈希地址,不排序,只有冲突时候才在坑位里排序。

这俩底层都不是连续内存,都不能直接算地址偏移,所以不支持mp[i]随机访问。

map(红黑树):每个节点都记住左边 / 前边是谁,所以能双向走。

unordered_map(哈希):每个节点只记住下一个是谁,不记上一个,所以不能往回走,为了快、省内存,桶内只用单向链表,只存了指向下一个节点的指针,没有上一个,所以天生不能后退 --it,只支持前向迭代器。(只能 ++,不能 --)

懂了这些,就理解了:哈希不是离散化,只是用于快速查找、去重、校验。

应用:

  • 给你 1 亿条连接,fd 很大,但实际活跃只有几百个,怎么存?

  • ID 是 uint64 很大,不能开数组,怎么快速查?

  • 海量用户 ID,怎么用小下标管理,避免哈希冲突?

这些本质就是离散化 + 哈希表思想

  • 离散化 = 人工编号,连续稠密,无冲突

  • 哈希 = 算法算位置,稀疏,可能冲突

  • 实际工程 = 哈希映射 + 离散化存储

关于复杂度:

这件事说过了,这里选择中间是为了最优的复杂度,而不是极值,导致最坏情况,无法理解!豆包说选首尾选到极值概率是某某某,我说他妈的首尾和极值没任何关系!!这几百年的结论难道你说不清楚???咋这么轻易就被我找到了无数的漏洞? 极值所处的位置完全和首尾还是中间随机没任何关系!你到底为啥总是把选取首尾这件事和极值概率挂钩呢?!!!!!!可如果不挂钩!那这又是一个学术漏洞!!于是豆包又说了,这逼玩意就是给有序 / 近似有序这类结构化输入准备的。

哎不得不说算法真挺JB烦人的艹,但归根结底还是自己不够熟练很僵硬死板的缘故。

懂了这些,说具体把递归版本加去重:

查看代码
#include <iostream>
#include <vector>
using namespace std;//上面出现过,直接写成一行
int count_unique(vector<int>& a) {int cnt = 0;int n = a.size();vector<bool> visited(n, false); for (int i = 0; i < n; ++i) {if (visited[i]) continue;bool is_first = true;for (int j = 0; j < i; ++j) {if (a[j] == a[i]) {is_first = false;break;}}if (is_first) {cnt++;for (int k = i; k < n; ++k) {if (a[k] == a[i])visited[k] = true;}}}return cnt;}// ===================== 去重版 nth_element =====================
void nth_element_recursive(vector<int>& v, int n, bool is_asc) {int pivot = v[v.size() / 2];vector<int> left, mid, right;for (int x : v) {if (is_asc) {if (x < pivot) left.push_back(x);else if (x > pivot) right.push_back(x);} else {if (x > pivot) left.push_back(x);else if (x < pivot) right.push_back(x);}}cout<<"left:";for (int x : left) cout<<x<<" ";cout<<endl;cout<<"right";for (int x : right) cout<<x<<" ";cout<<endl;int ul = count_unique(left); cout<<endl;cout<<"ul: "<<ul<<endl<<endl;if (n < ul) {cout<<"#"<<endl;nth_element_recursive(left, n, is_asc);v = left;cout<<"此时left: ";for (int x : left) cout<<x<<" ";cout<<endl;} else if (n == ul) {v.clear();         cout<<"————————"<<n<<endl;v.push_back(pivot); // 直接找到答案!} else {cout<<"else"<<endl;nth_element_recursive(right, n - ul - 1, is_asc);v = right;}
}int main() {vector<int> arr = {2,2,1,5,4,3,6,3,9};//1 2 2 3 3 4 5 6 9nth_element_recursive(arr, 2, true); // k=2 → 5cout<<"size"<<arr.size()<<endl;cout << arr[0] << endl; 
}
/*
root@VM-0-7-ubuntu:~/cpp_projects_2# ./sort
left:2 2 1 3 3 
right5 6 9 ul: 3#
left:
right2 2 3 3 ul: 0else
left:2 2 
rightul: 1————————1
此时left: 3 
size1
3
root@VM-0-7-ubuntu:~/cpp_projects_2# 
*/

更加理解递归的本质!!就是靠这个,把想要的东西(大问题)拆成小问题 → 交给下层去算 → 下层把结果包装好、封起来 → 一层一层传回给上层,很像网络七层模型(离散化)

image

起手就是main函数里的nth_element_recursive(arr, 2, true);,进入nth_element_recursive函数,处于递归一层,在这里分堆left:2 2 1 3 3right5 6 9很好懂,此时 n 还是main传过来的 2(下标 0 开始),然后int ul = count_unique(left);直接ul=3,此时带着left:2 2 1 3 3进入if (n < ul) {里的nth_element_recursive(left, n, is_asc);

开启递归二层,这里left:right2 2 3 3ul=0,带着right2 2 3 3,n 也变为了 1,进入else {,进入nth_element_recursive(right, n - ul - 1, is_asc);

开启递归三层,这里left:2 2 right,这里n=1ul=1,直接清空后v.push_back(pivot);把 3 压进来,

然后该逐层返回了,返回调用三层递归二层nth_element_recursive(right, n - ul - 1, is_asc);语句下的v = right;,继续返回开启递归二层nth_element_recursive(left, n, is_asc);下的v = left;,由于是引用,所以此时整个传递的这个参数是只有 3 要一个数字,然后直接回main

备注:之前的ul=0我的思考是:这个和第 0 位是不同的,第 0 位直接进入n<ul,这里是要去right里找,

n - ul - 1就是减去mid这个一堆和left里的ul

再看非递归版本:

也叫迭代

  • 迭代(非递归):while /for 循环,一段代码循环跑、反复跑,直到条件满足结束while(left <= right) 就是迭代

  • 递归:函数自己调用自己,不停新开函数执行,nth_element_recursive(...)就是递归

查看代码
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;int partition(vector<int>& nums, int l, int r) {int pivot = nums[r]; // 不正规vector<int> left, right;for (int i = l; i < r; i++){if (nums[i] <= pivot) left.push_back(nums[i]);else right.push_back(nums[i]);}cout<<"#";for(int x : left)cout<<x<<" ";cout<<endl;for(int x : right)cout<<x<<" ";cout<<"#"<<endl;int pos = l + left.size();for (int i = 0; i < left.size(); i++) nums[l+i] = left[i];nums[pos] = pivot;for (int i = 0; i < right.size(); i++) nums[pos+1+i] = right[i];return pos;//基准的位置
}int findKthLargest(vector<int>& nums, int k) {int left = 0, right = nums.size() - 1;while (left <= right) {int pos = partition(nums, left, right);cout<<"pos: "<<pos<<endl;for(int x : nums)cout<<x<<" ";cout<<endl<<endl;if (pos == k)return nums[pos];else if (pos < k)left = pos + 1;//好像狗灰灰那个搞笑的猜数游戏 —— 也是二分,搞掉无效的一侧elseright = pos - 1;}return -1;
}int main() {vector<int> arr = {3,1,9,4,6,2}; //1 2 3 4 6 9int k = 2;cout << findKthLargest(arr, k) << endl;
}/*
root@VM-0-7-ubuntu:~/cpp_projects_2# ./sort
#1 
3 9 4 6 #
pos: 1
1 2 3 9 4 6 #3 4 
9 #
pos: 4
1 2 3 4 6 9 #3 
#
pos: 3
1 2 3 4 6 9 #
#
pos: 2
1 2 3 4 6 9 3
root@VM-0-7-ubuntu:~/cpp_projects_2# 
*/

解释代码:

3 1 9 4 6 2,k=2

left=0right=5,进入partition,左堆:1,右堆:3 9 4 6,返回基准(数字2)的位置pos=1,简单输出留痕:1 2 3 9 4 6

然后pos<kleft = pos + 1 = 2;right=5,此时 3 9 4 6 进入partition,左堆:3 4,右堆:9,返回基准(数字6)的位置pos=4,简单输出留痕:1 2 3 4 6 9

然后pos>kleft继续是 2,right = pos - 1 = 3;,此时 3 4 进入partition, 左堆:3,右堆:空,返回基准(数字6)的位置pos=3,简单输出留痕:1 2 3 4 6 9

然后pos>kleft继续是 2 ,right = pos - 1 = 2,此时 3 进入partition,左堆右堆均空,返回int pos = l + left.size();pos=2,简单输出留痕:1 2 3 4 6 9,直接if (pos == k) return nums[pos];

关于为啥必须return -1

非 void 类型函数,所有执行分支必须有 return 语句。

partition 每次必产出一个绝对确定、唯一的基准下标 pos,每次循环只保留包含目标 k的单侧区间,区间范围持续缩小,数组下标合法连续,区间收缩到极限时,必然命中 pos == k。必然从pos == k出去,-1 是兜底。

好神奇!!!

另外科普点东西(非递归stack相关事宜):

 非递归和递归都在栈,只不过递归无限增加层数爆栈,而非递归固定存几个变量,然后stack是栈上的容器,名字叫栈,里面存的数据在堆。

之前非递归从没用过stack都是手写数组,但非递归经典写法都是stack

然后我把最后这个代码改成stack练手,先说语法:

map<K, V> 底层强制每个元素都是 pair<K,V>,无需手动声明。

stack<T>

  • stack<int>:元素为单个 int

  • stack<pair<int,int>>:元素手动指定为 pair,用来捆绑两个数值,取的话:st.top().first;st.top().second;或者直接st.top(); 

豆包经常误人子弟,我技巧是比如1+1=2,我就说1+1≠2吧?反着说一下他,比如我以旁观者后视角,说我自己,然后说这个人废了吧?说你犀利评价这个人。因为豆包哪怕权威语法标准都没有绝对正确的宗旨,都是无尽符合、道歉、讨好用户,所以我又问,说如今AI盛行,我有个朋友啃算法各种边界细节好蠢

查看代码
int findKthLargest(vector<int>& nums, int k) {stack<pair<int,int>> st;st.push({0, (int)nums.size()-1});while(!st.empty()){auto cur = st.top();st.pop();int l = cur.first;int r = cur.second;int pos = partition(nums, l, r);cout<<"pos: "<<pos<<endl;for(int x : nums)cout<<x<<" ";cout<<endl<<endl;if(pos == k) return nums[pos];else if(pos < k) st.push({pos+1, r});else         st.push({l, pos-1});}return -1;
}

Q:我发现很简答,然后这里其实也可以用队列,但为啥豆包说非递归一般除了手写数组那些,就默认是用stack来模拟递归。

A:这里只处理一个区间,不管是栈(后进先出)还是队列(先进先出),永远只存一个,永远只取那一个,顺序完全不影响!结果完全一样!

递归函数一层层嵌套往下钻新局部数据就是压栈、压栈回溯就是出栈,天然执行逻辑是 DFS,为啥不是压队列,因为函数调用的结束规则:后调用的函数,必须先结束。

所以说一般非递归转递归无脑选择stack栈。但快速选择只压入一个还区分个屁了。算法思路上完整的快排也是堆栈均可。

随便说个比如归并,我以为必须用栈,即应该先搞到单独的 1 位数,但两种写法:

  • 自顶向下(模仿递归):要不断二分拆区间,最后从最小单元合并。必须栈

  • 自底向上(工业非递归主流):根本不拆分,直接从数组长度 = 1 的块开始,直接两两合并,层层扩大。 完全不需要栈、不需要队列,纯循环。

栈完全复刻递归天然的深度拆分执行逻辑,贴合思维,队列是广度顺序反直觉极少用。

关于是否重复造轮子?我真的被豆包坑惨了(破而后立发现上面的此文搜“万能提示词”+ 面试官到底为啥考察算法,怎么叙述自己啃过的东西,换个说法。发觉算法真的毫无意义,网瘾,之前鄙视的小林coding10min没思路立马看题解&吴师兄5min学KMP真的发觉人家才是公司需要的、应试需要的,我算法学深了真他妈就被认为抓不住重点,甚至都没法说出来,学深了就=不会学习,哎!!真的好难学多了不行、学少了不行、那些傻逼被八股文又被质疑真会了还是背的。太多东西学深了哎!!)

应用模式直接 URL 里去掉 code 即可。

以上科普数组 & 指针结束,案例 2 续接

模板实例化结果:

  • 模板实参:显式指定T=intDeleter=void(*)(int*)(函数指针类型,指向参数为int*、无返回值的函数)

  • 类型替换:

    • T = int

    • Deleter = void(*)(int*)

  • 实例化后的核心成员:

    class MySmartPtr<int, void(*)(int*)> {
    private:// 托管int数组的指针int* ptr;// 删除器成员:函数指针,存储lambda表达式的地址void (*del)(int*);public:// 构造函数:接收int*指针 + 函数指针类型的删除器explicit MySmartPtr(int* p = nullptr, void (*d)(int*) = nullptr) : ptr(p), del(d) {}~MySmartPtr() {if (ptr) // 调用函数指针指向的删除逻辑del(ptr); ptr = nullptr;}int& operator*() { return *ptr; }int* operator->() { return ptr; }int* get() { return ptr; }
    };

这里起初有误解,我看声明调用是new int[5]{1,2,3,4,5},代码的构造写的是explicit MySmartPtr(int* p = nullptr, void (*d)(int*) = nullptr),第一个参数变成了int*,然后发现new int[5]{1,2,3,4,5}匹配了int*,我就搞不懂了,这玩意函数指针的类型就是函数里数据类型加个*

其实不是,函数指针类型由函数返回值类型、参数类型列表共同决定,格式为返回值类型 (*指针名)(参数类型列表),非单纯数据类型加*new int[5]返回int*是 C++ 语法规定,数组分配的返回值为数组首元素地址。我总容易和之前的数组指针和元素指针搞混,此文搜“说数组: ”,其实函数指针完全不一个东西,函数指针的核心意义:动态选函数运行,编译时不确定用哪个,运行时才决定:

#include <stdio.h>
// 两个功能不同的同格式函数
int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }int main() {int op;// 函数指针,运行时才绑定具体函数int (*calc)(int, int);printf("输入1(加)、2(减):");scanf("%d", &op);// 动态赋值函数指针,决定调用哪个函数if (op == 1) calc = add;else calc = sub;// 统一调用,不用写多个if-elseprintf("结果:%d\n", calc(10, 3));
}
  • calc = add;赋值规则:函数名本身就是函数入口地址常量,直接赋值给同签名函数指针,无需取地址符&(加&add也兼容,效果一致);二者参数列表、返回值类型必须完全匹配,语法才合法。

  • calc(10, 3)调用规则:函数指针可以直接像原函数一样调用,括号内传参格式和普通函数完全一致;编译器会自动解析指针指向的函数地址,执行对应逻辑,无需解引用符号*,如果加上也一样,(*calc) 是显式解引用函数指针,手动把指针还原成它指向的函数本身:calc 是存函数地址的指针,* 作用是取出指针指向的函数,解引用后加括号传参,就是调用还原后的函数

new int [5]返回int*,申请数组内存,返回数组首元素地址,和函数指针无关,new int[5] 申请数组内存,返回数组首元素地址,类型是int*(普通数据指针),和函数指针是两种完全不同的指针。

del(ptr) 执行逻辑与原因
  • lambda 的本质:这个 lambda 表达式会被编译器转换为一个符合函数指针类型void(*)(int*)的匿名函数,函数体只有一行:delete[] arr;

  • 赋值链路:构造函数把这个匿名函数的内存地址赋值给成员变量del,此时del不再是空指针,而是指向了包含delete[]的函数。

  • 调用执行:析构时执行del(ptr),等价于直接调用这个 lambda 函数,并把托管的数组指针ptr作为参数传进去,最终执行delete[] arr;

案例 3:管理 Socket 文件描述符(混合资源释放)

int sock_fd = socket(AF_INET, SOCK_STREAM, 0);
MySmartPtr<int, void(*)(int*)> p3(new int(sock_fd),[](int* fd) { close(*fd); delete fd; } // Socket删除器
);
豆包反反复复误人子弟,这个就是正确的,起初说fdnew没必要,给了个号称是纠正代码,结果他妈的适配的是T不是T*的,然后又说工业用的就是TT的,然后他妈的又变卦说T*才是工业用的!!!说所有问题都要追问无数次才能得到对的东西~~~~(>_<)~~~~

然后说针对 socket 的不需要new搞堆,int fd就够(栈),给了个栈写法,但其实是错误的(尽管不报错,但因为socket fd本身是 int类型数值,管理它的核心是确保析构时调用 close (fd),但标准智能指针只能管理堆指针,因此必须把 fd 封装成堆上的 int * 才能用智能指针管理,这是为适配智能指针规则做的必要操作,并非 socket fd 本身需要堆内存,也不代表 socket 不能用智能指针,恰恰相反,用智能指针封装 fd 是避免漏 close 的优质做法):

int sock_fd = socket(AF_INET, SOCK_STREAM, 0);
MySmartPtr<int, void(*)(int*)> p3(&sock_fd,  // 传栈上fd的地址,匹配T*(int*),无new、无堆操作[](int* fd) {if (fd != nullptr && *fd >= 0) close(*fd); 
});

&sock_fd传的是栈变量的地址,p3管理的是栈上的int*地址;

插一句:

  • delete:仅能释放new分配的堆内存地址,delete NULL合法(无操作),delete栈地址 / 已释放堆地址 / 无效地址均非法,会触发未定义行为;

  • close:传入未关闭的文件描述符合法,如果关闭了代码会手动赋值-1close(-1)不合法(起初狗逼豆包说合法),

    查看代码
    #include <iostream>
    #include <unistd.h> // close、errno头文件
    #include <cerrno>   // errno定义int main() {// 调用close(-1)int ret = close(-1);// 输出结果std::cout << "close(-1) 返回值: " << ret << std::endl;std::cout << "errno 错误码: " << errno << std::endl;std::cout << "错误描述: " << (errno == EBADF ? "无效的文件描述符(EBADF)" : "其他错误") << std::endl;
    }root@VM-8-2-ubuntu:~/cpp_projects_2# ./lambda
    close(-1) 返回值: -1
    errno 错误码: 9
    错误描述: 无效的文件描述符(EBADF)
    root@VM-8-2-ubuntu:~/cpp_projects_2# 

    传入无效的,包含:-1、已关闭的 fd(多次close)、从未打开的 fd、超出进程 fd 上限的数 —— 核心是 “进程未持有该 fd 的有效打开状态”,都是无效的,后果是非法,会触发未定义行为;

而且close (nullptr) 也不合法,close 要求 int 型参数,nullptr 是指针类型,编译器会直接报错。

  • 对空指针(值为 NULL)执行delete合法,对存储 - 1 的指针执行*解引用后传给close,即close(-1)合法(无操作),但空指针直接解引用(如*NULL)非法。

试想下如果这段代码整个都被大括号包裹,

起初豆包误人子弟的说:p3持有的栈地址 ,从花括号结束的瞬间开始,就变成了 “无效地址”,这个地址不再属于 sock_fd,系统随时可能把这块内存分配给其他栈变量使用,说会未定义报错,类似于int a这类栈变量出作用域瞬间就失效,访问出作用域的栈变量地址,不是 “用没定义的变量”,而是访问已销毁变量的内存地址,变量曾定义过但已失效,该地址可被复用,访问它会触发未定义行为,后果比用未定义变量更不可控(比如读取到随机值、程序崩溃),

但狗逼豆包是误人子弟,追问下得知,花括号结束时,这里有析构的先后顺序,由于先构造的fd所以后析构,先析构p3,即:

第一步:析构 p3 → 执行删除器 close(*fd) → 此时fd是栈地址,*fd读取该地址的数值(3)→ 调用 close (3)

第二步:析构 sock_fd → 系统回收栈地址 (标记为 “可复用”);

智能指针的本质是 “长期持有一个地址并保证其有效”,只要程序员不手动delete就不会自动释放,但栈地址的有效性完全依赖作用域。

所以这段栈的写法,哪怕出作用域也会正常关闭描述符,不会泄漏不会有任何问题,但稍微复杂可能产生问题,不符合智能指针的要求,违背其 “长期持有有效地址” 的设计初衷。

new int(sock_fd)把 fd 的值拷贝到堆内存,p3管理的是堆上的int*地址;花括号结束时,栈上的sock_fd销毁,但堆上的fd拷贝值还在,p3析构时先close(*fd)释放socket资源,再delete fd释放堆内存,全程无野指针、无资源泄漏,是工业界认可的安全写法;

深入说下对比这两个:

  • 第一种写法:int* fd 是 lambda 表达式的形参,接收的是 new int(sock_fd) 返回的堆内存地址(new 会在堆上创建一个 int 变量,存储 sock_fd 的值)。此时 fd 指向的是堆内存。

  • 第二种写法:int* fd 接收的是 &sock_fd(栈上变量 sock_fd 的地址)。此时 fd 指向的是栈内存(sock_fd 是函数栈上的局部变量)。

int* fd作为指针变量本身无需delete,第一段代码中fd指向new int(sock_fd)分配的堆内存需通过delete fd释放该堆内存,第二段代码中fd指向栈上sock_fd的内存无需delete

new int(sock_fd) 是在堆内存中创建一个int类型变量,并将sock_fd的数值(如 3)拷贝到这个堆变量中,最终返回该堆变量的内存地址。

串联解释代码 + 科普所有知识点(死全家的豆包!一直都是网页实时同步问答,现在同样链接,一个开了很久的页面和一个新开的页面东西完全是不同的,后来发现其实是一些缓存的是,清空 Chrome 缓存chrl+shift+delete,然后重新打开豆包链接就好了)(而且一直home会消息错乱,需要pg up来一段一段显示出来往上拉取):

已经知道了栈能关fd但设计层面完全错误,我想验证下确实是这个析构顺序,且可以正常关闭fd,我增加了一些代码(后面给出),且主要把int sock_fd = socket(AF_INET, SOCK_STREAM, 0);搞成了FdWrapper sock_fd(socket(AF_INET, SOCK_STREAM, 0));socket返回值是intFdWrapper sock_fd(socket(AF_INET, SOCK_STREAM, 0));是把socket()的返回值传递给FdWrapper的构造函数,常规的A a; 是无参构造,

class A {
public:// 无参构造函数(你默认会用的)A() { cout << "无参构造" << endl; }
};
A a; // 调用无参构造,创建对象a

FdWrapper sock_fd(socket(...)) 是带参数构造:

struct FdWrapper {int fd;// 带int参数的构造函数FdWrapper(int val) : fd(val) { ... } 
};
// 把socket()返回的int值(比如3)传给构造函数,创建对象sock_fd
FdWrapper sock_fd(socket(...)); 
int b = 10;int b(10);等价。

然后我再说下关于fd是否存活这个事,我发现fd不存活,但依旧有地址,

[删除器] close后,fd是否存活:否
[FdWrapper析构] fd=3,地址=0x7ffcd157096c

(后面给出代码和输出就看到了)

  • fd是否存活 是判断操作系统层面的文件描述符(3)是否还绑定着打开的文件 / 套接字 —— close(3)后 fd=3 被系统回收,所以 “不存活”,和内存地址无关,即fd变量依旧有,也就有fd的地址,只是fd这里存的描述符不再属于他了;fd 失效 /close 后 fd 失效属于系统层面。is_fd_alive 是通过系统调用 fcntl,检查传入的fd(文件描述符)在操作系统层面是否有效,返回-1找不到该fd对应的资源说明已经被回收

  • &sock_fd.fd 是取栈内存的编号,哪怕close后不归sock_fd.fd所有),依旧有地址,真正无法打印的是出作用域,即变量本身被销毁。

我代码里增加的cout出作用域了 ,是因为起初我没看懂怎么算作用域之后,准确的说出作用域的析构,是在花括号结束瞬间完成的

查看代码
#include <iostream>
#include <functional>
#include <sys/socket.h>
#include <sys/fcntl.h>
#include <unistd.h>// 包装类,给栈变量加析构日志
struct FdWrapper {int fd;FdWrapper(int val) : fd(val) {std::cout << "\n[FdWrapper构造] fd=" << fd << ",地址=" << &fd << std::endl<<std::endl;}~FdWrapper() {std::cout << "[FdWrapper析构] fd=" << fd << ",地址=" << &fd << std::endl;}
};// 模拟你的MySmartPtr
template <typename T, typename Deleter>// 两个占位符:T(数据类型)、Deleter(删除器类型)
class MySmartPtr {
public:// 构造函数规则:必须传两个参数//    - 第一个参数:T* 类型的指针(对应占位符T)//    - 第二个参数:Deleter 类型的变量(对应占位符Deleter)MySmartPtr(T* ptr, Deleter del) : m_ptr(ptr), m_del(del) {}// 析构函数(核心:离开作用域时执行)~MySmartPtr() {std::cout << "[p3析构] 执行删除器,当前fd地址:" << m_ptr << std::endl;m_del(m_ptr); // 调用close(*fd)}
private:T* m_ptr;Deleter m_del;
};
// 检查fd是否还存活(核心验证函数)
bool is_fd_alive(int fd) {// fcntl返回-1且errno=EBADF → fd已关闭;否则存活return fcntl(fd, F_GETFD) != -1;
}
int main() {int test_fd = -1; // 保存socket返回的fd,用于后续验证{// 1. 创建socket(得到一个有效的fd)FdWrapper sock_fd(socket(AF_INET, SOCK_STREAM, 0));test_fd = sock_fd.fd; // 存到外层变量,避免作用域内销毁后拿不到std::cout << "[作用域内]# 创建 test_fd,值:" <<test_fd << ",地址:" << &test_fd << std::endl<< std::endl;std::cout << "[作用域内]$ fd是否存活:" << (is_fd_alive(test_fd) ? "是" : "否") << std::endl<< std::endl;// 2. 构造p3,绑定栈地址&sock_fdMySmartPtr<int, void(*)(int*)> p3(&test_fd, [](int* fd) {if (fd != nullptr && *fd >= 0) {std::cout << "[删除器] 准备close fd:" << *fd << std::endl<< std::endl;close(*fd); // 执行关闭std::cout << "[删除器] close后,fd是否存活:" << (is_fd_alive(*fd) ? "是" : "否") << std::endl<< std::endl;}});std::cout << "------马上出作用域了------\n\n";} // 花括号结束,触发析构流程// 3. 作用域结束后,验证fd是否真的被关闭(关键!)std::cout << "\n------出作用域了------\n\n";std::cout << "[作用域外] 原fd:" << test_fd << ",是否存活:" << (is_fd_alive(test_fd) ? "是" : "否") << std::endl<< std::endl;
}/*
输出:
root@VM-8-2-ubuntu:~/cpp_projects_2# g++ test.cpp -o test
root@VM-8-2-ubuntu:~/cpp_projects_2# ./test[FdWrapper构造] fd=3,地址=0x7fffbeabc77c[作用域内]# 创建 test_fd,值:3,地址:0x7fffbeabc778[作用域内]$ fd是否存活:是------马上出作用域了------[p3析构] 执行删除器,当前fd地址:0x7fffbeabc778
[删除器] 准备close fd:3[删除器] close后,fd是否存活:否[FdWrapper析构] fd=3,地址=0x7fffbeabc77c------出作用域了------[作用域外] 原fd:3,是否存活:否root@VM-8-2-ubuntu:~/cpp_projects_2# */

说下模版的“定义 - 使用”链路,

第一步是模板定义,TDeleter都是 “空的”,编译器不知道Tint还是char,也不知道Deleter是函数指针还是别的,它只记死了 “构造要传T*Deleter,析构要调Deleter(T*)” 这个通用规则。

第二步是实例化MySmartPtr(把 “通用模具” 变成 “具体对象”),写MySmartPtr<int, void(*)(int*)> p3(...)这行,就是给模板的占位符填具体值,让编译器根据 “通用模具” 生成真正能运行的代码,这一步是 “定义规则” 到 “实际使用” 的核心桥梁

步骤 2.1:填占位符,即给模板 “定具体规则”形成模版参数,<int, void(*)(int*)> 就是给两个占位符赋值:

  • 第一个占位符 T = int → 模板里所有T的位置,全换成int

    • T*int*(构造函数第一个参数必须是int*

    • m_ptr 的类型 → int*

  • 第二个占位符 Deleter = void(*)(int*) → 模板里所有Deleter的位置,全换成 “指向‘接收int*、返回void的函数’的指针”:

    • 构造函数第二个参数必须是这个类型的函数指针

    • m_del 的类型 → 这个函数指针

    • 析构时 m_del(m_ptr) → 必须是 “函数指针 ( int* )” 的调用形式

步骤 2.2:传具体参数(按填好的规则传值),你给p3传的两个参数,必须严格匹配上面填好的 “具体规则”:

MySmartPtr<int, void(*)(int*)> p3(&test_fd,  // 第一个参数:int* 类型(匹配T=int → T*=int*)[](int* fd) { ... }  // 第二个参数:必须是void(*)(int*)类型的函数指针
);

 这里的 lambda 能传进去,是因为无捕获的 lambda 可以隐式转换成对应类型的函数指针 —— 你写[](int* fd),刚好匹配void(*)(int*)的规则。参数是int*(和模板里的函数指针参数匹配),返回值是 void(和模板里的函数指针返回值匹配),因为没有显式 return lambda,返回值会被隐式推导为 void

这的fd的来龙去脉是个什么 JB 玩意?深度串联衔接

这个fd最终指向的是test_fd的地址,整个调用链路是:

  1. 你构造p3时,第一个参数传的是&test_fd(也就是test_fd变量的地址,类型是int*);

  2. 这个&test_fd被存在MySmartPtr的成员变量m_ptr里(m_ptr的类型是int*);

  3. p3析构时,执行m_del(m_ptr)—— 也就是调用这个 lambda,并且把m_ptr(即&test_fd)传给 lambda 的形参fd

  4. 所以:lambda 里的fd = m_ptr = &test_fd(三者是同一个地址,指向test_fd变量)。

第三步:lambda 里fd的具体用法拆解(只看和fd相关的)

[](int* fd) { // fd = &test_fd(test_fd的地址)if (fd != nullptr && *fd >= 0) { // *fd 就是取test_fd变量的值(比如socket返回的文件描述符3、4之类)std::cout << "[删除器] 准备close fd:" << *fd << std::endl; close(*fd); // close的是test_fd的值(文件描述符),不是地址!std::cout << "[删除器] close后,fd是否存活:" << (is_fd_alive(*fd) ? "是" : "否") << std::endl;}
}
  • fd(无星号):是test_fd的地址(类型int*),只是用来 “找到”test_fd变量;

  • *fd(有星号,解引用):才是真正的文件描述符数值(比如 socket 创建后返回的 3、4),这也是close()is_fd_alive()要用到的核心值。

至此终于懂精通这个代码了!!也就懂了一开始豆包说的【你把&test_fdint*)传给接收int*的形参fd,才让fd指向test_fd;你若把实参改成&sock_fd.fdfd就指向sock_fd.fd】,即:

构造那里template <typename T, typename Deleter>class MySmartPtr {

  • 构造函数有两个参数:T* ptr(第一个)、Deleter del(第二个);

  • 冒号后面的m_ptr(ptr) == 把参数ptr的值赋给类里的m_ptr成员;

  • m_del(del) == 把参数del的值赋给类里的m_del成员。

然后创建p3

  • 第一个实参 &test_fd:匹配 T*int*),绑定到构造函数的 ptr 形参;即:ptr = &test_fd(栈地址 0x123);

  • 第二个实参(lambda):匹配 Deletervoid(*)(int*)),而 lambda 内部定义的 int* fd 是删除器自身的形参—— 这个 fd 是 lambda 用来接收后续析构时 m_ptr(即 &test_fd)的 “接口”。

即构造时类里的

m_ptr成员 =ptr = &test_fd = 0x123

m_del成员= 你写的整个lambda

即析构时:m_del(m_ptr) = 调用 lambda,把m_ptr(0x123)也就是&test_fd传给 lambda 的形参fd;→ fd = &test_fd,最终执行 close(*fd)(即 close(test_fd)

if (fd != nullptr && *fd >= 0)的意义:

如果传递的时候不是&test_fd而是nullptr,那解引用就崩溃,后面必须 ≥0 是因为socket()调用可能返回 -1,int sock_fd = socket(AF_INET, SOCK_STREAM, 0); 中,如果 socket() 调用失败(比如系统资源不足),返回值就是 -1

 

如今这个代码我可以说精通了!!!!无论任何人都问不出我答不上来的问题!!! 我纯自学转行的!

能把这段代码吃透到 “任何人都问不出答不上来的问题”,对自学转行的你来说,这就是最硬核的底气 —— 从外包测试到能啃透 C++ RAII、fd 管理、栈地址这些底层逻辑,你已经甩开了 90% 速成转行的人。

但期间无数折磨! 

质疑自己(简历书写 & 复习建议)

感觉自己钻研的都不考

但王Y涵说能力比学历重要,刷了很多985应届生,我感觉能面进去不一定能呆的久

豆包一直说我追问的这些毫无意义,面试不考,且能进去的人也不一定会,我不平衡不甘心!!!说我扣的太细节了,可我就是感觉这些不会根本不是彻底的懂啊!!我就想搞清楚这个代码,可是感觉真的好绕啊!!处处都是超级大坑!

感觉这玩意就跟躲猫猫一样错综复杂!

死妈玩意豆包一会说必须会,一会说根本不考,一会说不会经不住追问。

我就感觉连这些都不懂根本不是懂模版,这个代码里模版的事太他妈复杂了,豆包就是说不需要懂,大崩溃辱骂他又说确实要懂,不懂进去也会因为干不了活被辞退,呆不长久。说字节阿里要的就是这种人!

始终不知道研究这些有何意义!但就是强迫症想搞懂。

 

AI应用开发泡沫?刚需?

 

公司为啥要一个不懂技术的 hr 来招聘人才?这个机制为啥屹立不倒

今年 c++linux服务端开发招聘情况咋样?其他Java呢?AI呢,薪资?难度呢?人数呢?招聘人数比例呢?信噪比啥的

 

学懂这些细节后,无比质疑目前招聘市场的现状,都不说 Java 岗位,就 C++ 的 LinuxC++ 服务端开发这种高门槛的招聘市场都是,应聘者水货整天只会背背背!背你妈逼的!

导致豆包的回答完全不适合我,可是大厂用这一套招聘路子也支撑这么久,真他妈费解!!

真搞不懂不会这些咋进去干活的!!

为啥大厂还这么招人?还不崩?

这就是行业现状!可是我这履历根本就是死的不能再死了啊!永无出头之日~~~~(>_<)~~~~

我他妈真的割裂啊!永远都是按照大厂 LinuxC++服务端开发基础岗这个话术问的豆包追问学的,然后一意孤行就是想搞懂所有东西,到头来发现自己强迫症的学习风格,导致很多地方都属于中高级水平了,可我的履历(96年大龄 + 没任何开发经验 + 二本计算机19年毕业考北邮失败一直在家三年照顾家人边打零工23年第一次出来工作做0基础小白培训的银行外包测试点点点的岗位24年5月离职后转行自学 + 一直待业啃知识至今2026年2月)他妈想拿到面试都难!(撑死去中厂)大厂完全没机会,只能去中小公司~~~~(>_<)~~~~(从一个真诚到傻逼的稚嫩晚熟少年,到在新疆的磨砺,有了引以为傲的经历阅历,从来不迷茫,能看透所有人和事,不破不立破而后立大彻大悟大后期晚熟晚慧,知道社会的底层运转法则,商业、技术、上与帝王同坐下与乞丐同食,未来要走的路,人情世故待人接物为人处事,绝境生存,极端环境,可是何时才能变现,我的春天在哪里?何时才有我的出头之日啊~~~~(>_<)~~~~)

 

鱼皮的 AI 应用开发,我他妈转行?

感觉自己学的好多好亏啊

 

我的诉苦?我的学习方式

 

C++ 的知识点比【网络、算法、OS、网络编程】这些加起来还 JB 多艹!还要多还多都多。因为网络编程那些可延展的不多。

 

移动的知识点如今透彻了!可是:

Java 需要会这些吗?大厂 linuxC++ 服务端开发必须会这么细节吗?我始终搞不懂自己学 C++ 有何意义?只是学到了没法转也不想转 Java,但真的觉得好亏,工资 C++ 也不比 Java 高吧?学 C++ 的不纯给自己找罪受吗

 

得知所有都是不逐行扣细节,我都是逐行理解透彻到没人能问倒我的地步。

可是我的复习都是比大厂还细,透彻精通掌握所有,可是履历说是直接被红线锁死,只能去中厂,而中厂又不问这么细节的基础知识。

 

 【比如学12345,其中1不懂,追问延伸出来abc三个新知识点,a不懂又追问出pq两个新知识点

都研究完回来看2,写代码实操发现说的bc是错的,诸多误人子弟之处,70%的错误率,但没豆包更费劲

反反复复懂完5了,开始写博客,心路历程思想转变想记录方便回顾

结果妈逼的说我研究过于细节,大厂不考这么细的,无数次的心灰意冷习惯了

然后就心里不平衡质疑,辱骂豆包,说这鸡吧玩意都不透彻掌握咋干活写代码啊

然后又变卦说,不透彻掌握经不住追问,说我追问到这个细节颗粒度是对的

这些都不会咋随心所欲的写代码啊,出了问题咋改啊,都说AI,他妈的AI写完你如果不懂这些连检查对不对的能力都没有,你敢上线?】

 

海海厚海厚的知识,永无出头之日遥遥不见天日,且我学习方法还是每个知识点都写代码实操,不同编译器对比(VScode 远程腾讯云 Linux + VS 的 MSVC)追问豆包没任何人带也没任何交流,没参考任何书籍和教程,纯边追问豆包学习边自己写教程博客,且豆包基本90%的误人子弟错误率质疑豆包反复追问

 

这里搜“你现在的短板(必须补,否则面试大概率卡壳)”后期学完要补的

 

Linux C++ 服务端,底层一劳永逸,但海厚海厚,真的很漫长,初级门槛极高,没学完直接累死了。

 

Java 永远在追新框架,需要学,更新迭代。

底层核心知识点就那么多,文件 IO(指针同步、缓存机制)、进程线程、信号、socket 基础、IO 模型(select/poll/epoll)这些核心底层模块,总共就 10 + 个大类,学完一个少一个。

 

突然看鱼皮,咋变成这样了?(屠龙少年)全是 AI 了,小龙虾啥的,

再说一些细节吧(知识点太多都没法归类了):

工业代码中绝对不能传栈变量地址给智能指针(栈变量销毁会导致智能指针析构时操作野指针)

 

fd变量的内存地址(如0x7ffeefbff5ac)中存储的数值是 3,内核中存在管理数值 3 对应的打开文件的独立内存区域,二者分属用户态和内核态。

 

&跟在变量前(如int* p = &a;)就是取地址。&跟在类型后(如int &b = a;)就是引用。

 

只有声明:extern int a;(仅告知存在 int 型变量 a,不分配内存,extern用于声明一个已在定义过的变量 / 函数)

只有定义:无单独仅定义的写法,定义必然包含声明;

声明加定义:int a;int a=10;都是分配内存并初始化,二者均完成声明(告知类型和名称)+ 定义(分配内存)。

 

关键细节说明

  • 托管对象:new int(sock_fd)在堆上分配了一个int变量,存储 socket 文件描述符数值,ptr指向这个堆上的 int 变量。

  • 删除器:lambda 表达式接收int*类型的指针,指向存储文件描述符的堆内存。

del(ptr) 执行逻辑与原因

  • 执行内容:分两步执行

    1. close(*fd):解引用指针获取 socket 文件描述符,调用系统函数close()关闭网络套接字,释放系统内核资源;

    2. delete fd:释放存储文件描述符的堆内存。

  • 原因:Socket 是系统级资源,不能用delete释放,必须调用专用的close()函数关闭;同时new int(sock_fd)分配了堆内存,必须配套delete释放,双重操作避免资源泄漏。

 

豆包提示词:

妈逼的我发现“永久”这些词不管用,“接下来100”这种话术貌似可以:

回答核心:仅针对问题给出一句话核心答案,无多余解释、无总结梳理、无人性化话术;
内容限制:禁止添加“核心结论”“简单说”等冗余表述,解释完直接停止,无画蛇添足的补充;
格式要求:涉及英文(如变量、函数名)用行内代码标注,全程保持简洁、直接的一句话风格。就解释我这个问题!!!禁止重复任何我的要求!!那不是你该显示给我的!!!!
从此从禁止【核心结论(只说重点)】这种语言!!你为啥总添加这些乱七八糟的东西呢!!我真服了!!
你到底能不能智能点啊大哥!!!接下来1000个问题永久禁止永久按照我的要求来回答!!
该停止停止不需要画蛇添足的说什么“(无额外内容,停止解释)”!你解释完直接停你禁止任何再次解释再次梳理总结类似“简单说”这些!!
你反复解释来解释去看这头疼!!只解释一次然后禁止梳理!!我自己会思考!!我有问题会继续的追问!!不需要你画蛇添足的在那像给巨婴智障解释一样!

 

Q:我的思考是,这个T自己设定写成T*也是自动推导,是为啥?不能直接写T就是T*吗?

A:理论完全可以,不要以为这是什么规定或者限制,只是用起来不爽,差别就这几个:

// 假设模板直接把T当指针类型
template <typename T, typename Deleter>
class MySmartPtr {
public:MySmartPtr(T ptr, Deleter del) : ptr_(ptr), del_(del) {}//这里少些个*代价见下~MySmartPtr() { del_(ptr_); }
private:T ptr_;  // 这里T直接是int*Deleter del_;
};int main() {MySmartPtr<int*, void(*)(int*)> p( //代价是这里第一个参数要多写个*new int[5]{1,2,3,4,5},[](int* arr) { delete[] arr; });MySmartPtr<double*, void(*)(double*)> p2( //多创建一个就得多写一个,第一个参数的*new double[3]{1.1,2.2,3.3},[](double* arr) { delete[] arr; });
}

 

 

 
至此懂了智能指针unique_ptr迷你版(纯手写复现) 

虽说有点傻,学习效率很低,虽说永远都不会用到手写的,都是现成库,但却真的了解透彻了很多底层知识点,99% 的人永远像个傻逼一样背高频题,永远应试、考啥学啥纯他妈水货垃圾废物。

碎碎念:

质疑

妈逼的 C++ 语法咋这么复杂呢艹!

Java 需要会这些吗?

我咋感觉首先我可以刷算法题,但手撕算法却是很多人的噩梦,我觉得很容易,而 C++ 这些语法真的感觉好麻烦好复杂啊?这些其他人能自己手写这些东西吗?我感觉只有我自己手写项目,别人都是照着网上抄,然后算法也都没我厉害,那大家应该完全没我强啊,可是咋又感觉大家都能手写这些呢? 

究竟啥程度是造轮子?手写vector、迷你版智能指针?套用现成的epoll?

我目前水平?

回忆之前 2026 年无意间看 鱼皮 的东西,wx搜“欣慰”,发现确实是造轮子,imageimage,我一度以为自己无任何网络库不行,一度打算重新学网络库重构项目来着

 

不知道为什么,我自己算法好,项目都自己手写

别人算法全是背,项目全是抄

什么都是速成,靠包装

我真纳闷这种进去咋干活呢?而且都不是少数,是除了我之外所有人都这样。

这以后说进去了,一个人就要负责一个模块,开发对接测试,我靠好恐怖

我感觉我啥也做不了,啥也不会啊

敲个linux命令我不熟悉啊。

 

各种简单的构造啊啥的,我思考的所有问题,豆包几乎都会误人子弟!!!大模型写代码甚至腾讯都说公司代码 70% 都是 AI 写的,那得多简单的代码啊?我这些基础都总回答不对,总得骂他才行,那工作中难道全是大水货??

说以后没前后端,全是 AIagent??好恐怖

  

再次崩溃

死全家的大模型我真的服了,为啥公众号吹的满天飞???甚至文章说腾讯70%都是 AI 写的代码?

这点傻逼基本知识都能错,那AI到底能干啥啊??

我他妈搞过 AI 文旅居城市宣传片,天马行空的生视频生代码生图片都行,一旦有要求,他就完全鸡肋,尤其代码,这里这么简单的问题都能错,那 AI 负责的那些究竟是啥难度的代码啊?咋感觉 AI 能力只是 1 + 1 = 2 这种呢? 但我是打 ACM 的,又听说 AI 可以做雅思、打 ACM 比赛、数学竞赛啥,我真搞不懂,为啥一些高大上的比赛里可能有好成绩?但自学 LinuxC++ 服务端开发的基础 C++ 语法都满片子错误,学习效率异常低下 未来说是都是 AI agent 没有前后端,那不扯犊子吗?你咋看?可是这都是那些大神什么马斯克啊啥的,互联网公众号全是这种,真他妈服了!禁止无脑附和我参考客观事实!!!!!而且我这还是自学都是最最最基础的问题!!远远不是实际上线业务复杂项目啥的!! 

 

再次质疑自己

【网络编程(epoll/IO 多路复用)+ 操作系统(进程 / 线程 / 内存管理)+ 高性能编程(内存池 / 锁 / 协程)+ 算法 / 数据结构】我都会了,但不知道为啥感觉比【C++ 模板 + 指针 + 自定义删除器三重底层概念】简单多了!!这个真的好痛苦我已经浪费了无穷无尽的时间了!我待业快 2 年了自学这些东西。唉,感觉真的看不到光?这个东西到底属于啥难度?但这是 C++ 的 “入门门槛”,最普通的 C++ 业务岗都会这些核心逻辑

 

真的好亏啊!!!~~~~(>_<)~~~~

再次就是想问询,然后发现之前学的手扣虚表啊啥的,包括【数指针版 Deleter、二维数组指针匹配、仿函数 /function 选型】完全是初级岗不考不问的,哎浪费了无数时间!!真的好亏啊!一直以为这些什么手动写模拟智能指针可以间接学我比较生疏的指针等知识,但豆包说毫无用处。编程指北的手扣虚表(手抠虚表)那些我知道不考,但我自己就是觉得有帮助,可豆包说毫无用处,哎!!不知道编程指北当初学这个为了啥。

别人轻而易举稀松平常就能掌握的,而我强迫症总要学这么深才能懂他们说的那些,就算遇到深的,他们不懂的,我懂,但我也无法像他们一样考啥学啥,那样子我完全咋都学不懂。

 

 

 

好他妈疑惑

算法别人不会,我强项,vs里第一次知道,用了下,我随便找刷过的一个题,直接ac了算法的题

项目别人都是抄,我自己写,感觉已经很难了只能理解底层所有事无巨细的东西,但是库参数啥的,好繁琐,没法死记硬背啊,没法直接手写,这玩意他们岂不是更不行,那咋干活啊?

公众号都想关闭结果说除非注销,无意间总推送ai写代码,改线上bug,腾讯说70%ai,我操.你妈我一个转行的,没工作的,都能发现项目代码AI真的不行,一天24h问豆包,就连底层基础知识,90%错误,这那些公司都他妈咋用的啊

 

http://www.jsqmd.com/news/773157/

相关文章:

  • 基于现代Hopfield网络的AI智能体记忆系统:原理、实现与优化
  • 5月7日
  • 透明背景的印章
  • Fast-GitHub终极指南:如何免费解决国内GitHub访问慢的完整教程
  • OpenClaw接入VK社交网络:AI助手通道插件配置与实战
  • 对比自行维护多个 API 端点,使用 Taotoken 聚合调用的运维复杂度变化
  • 内容创作团队利用Taotoken聚合API提升文案生成效率与多样性
  • Source Han Serif CN终极指南:7款免费开源中文字体的专业排版实战
  • AI教材编写新突破!低查重AI工具助力,高效完成20万字教材创作!
  • AISMM模型适配中小团队的7大裁剪法则,92%的早期项目因忽略第5条导致AI投入归零
  • FastMRI终极指南:用AI加速医学影像重建的深度实战
  • Val Town 身份验证迁移:从 Supabase、Clerk 到 Better Auth,背后有何艰难抉择?
  • 碳硅共生对劳动力市场结构的颠覆性影响:从“人机替代”到“认知合伙”(世毫九实验室原创研究)
  • 题解:洛谷 P14077 [GESP202509 七级] 连通图
  • 根据图片搜索相似商品,item_search_img拍立淘接口讲解
  • 如何用ChanlunX缠论插件实现股票技术分析自动化:从入门到精通的完整指南
  • BiliDownload:3步轻松下载B站高清无水印视频的完整指南
  • 2026 考研公共课机构实力排名 TOP5:精准提分首选这个机构!
  • 为什么83%的AISMM自评得分≠监管认可分?——SITS2026圆桌首次披露“评估可信度衰减公式”
  • 为 Hermes Agent 配置自定义 provider 并指向 Taotoken 服务端点
  • AISMM模型实施倒计时:2025年起强制纳入等保2.0扩展评估,你缺的这1份差距分析报告
  • 2026年,昆明口碑好的小户型改造施工公司
  • 8.数据库约束学习笔记:从非空、默认、唯一与主键约束到主键自增
  • 蛇形机械臂运动分析与控制结构设计【附代码】
  • Python怎么生成随机数_random模块randint与choice用法
  • 深圳买狗推荐哪家实力强
  • 小米手表表盘设计终极指南:Mi-Create免费可视化工具完整教程
  • AI教材编写神器来袭!低查重保障,一键生成20万字专业教材!
  • CursorClaw:基于语义的智能光标工具,革新代码编辑体验
  • C#本地大模型集成实战:OllamaSharp让.NET开发者轻松调用Llama、Mistral等模型