惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Engineering at Meta
Engineering at Meta
T
Threatpost
P
Palo Alto Networks Blog
NISL@THU
NISL@THU
O
OpenAI News
Project Zero
Project Zero
G
GRAHAM CLULEY
P
Privacy International News Feed
A
Arctic Wolf
Microsoft Azure Blog
Microsoft Azure Blog
H
Help Net Security
M
MIT News - Artificial intelligence
T
Threat Research - Cisco Blogs
S
Security @ Cisco Blogs
Google DeepMind News
Google DeepMind News
B
Blog RSS Feed
D
Docker
aimingoo的专栏
aimingoo的专栏
博客园 - 【当耐特】
N
Netflix TechBlog - Medium
云风的 BLOG
云风的 BLOG
雷峰网
雷峰网
W
WeLiveSecurity
P
Proofpoint News Feed
腾讯CDC
Cloudbric
Cloudbric
S
Secure Thoughts
C
Check Point Blog
博客园 - Franky
T
The Exploit Database - CXSecurity.com
T
Troy Hunt's Blog
GbyAI
GbyAI
Security Archives - TechRepublic
Security Archives - TechRepublic
Application and Cybersecurity Blog
Application and Cybersecurity Blog
月光博客
月光博客
C
Cyber Attacks, Cyber Crime and Cyber Security
I
Intezer
TaoSecurity Blog
TaoSecurity Blog
L
Lohrmann on Cybersecurity
V
Visual Studio Blog
F
Fortinet All Blogs
博客园 - 叶小钗
C
CXSECURITY Database RSS Feed - CXSecurity.com
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
Recorded Future
Recorded Future
C
Cisco Blogs
博客园 - 司徒正美
Stack Overflow Blog
Stack Overflow Blog
Y
Y Combinator Blog
Apple Machine Learning Research
Apple Machine Learning Research

博客园 - GerJCS

《编程指北》动手写 C++ shared_ptr 章节开始(没钱买书,靠无尽追问豆包,反复思考质疑,一路拓展出很多东西,包括手写了个内存池,因为始终感觉没什么东西都是枯燥的概念对比,用Linux实践malloc又看不到RES回落,于是不知不觉摸索到了内存池) 《编程指北》动手写 C++ shared_ptr 章节开始(没钱买书,靠无尽追问豆包,反复思考质疑,一路拓展出很多东西,包括手写了个内存池,因为始终感觉没什么东西都是枯燥的概念对比,用Linux实践malloc又看不到RES回落,于是不知不觉摸索到了内存池) 续啃:编程指北 C++ (从 RAII开始的,RAII这一小节,学了将近两个月,RAII 的内容早在之前就主动追问豆包搞懂了,这节主要是自己无尽追问探索出很多其他知识,后来发现其实堪比精啃 CSAPP & APUE 等圣书) 编程指北的 C++ 续:啃操作系统 项目:从零开始做一个HTTP服务器(准备篇 —— 无尽弯路错路) C++:继续上一篇文章学CGI编程:乱码问题、误入歧途学了CGI编程(其实只是安装劝退因祸得福)、通过知乎直答及时更改学习方向、找到方向(花了2天) C++:网页网站、互联网、服务器、(web)服务器、(http)服务器、超文本、大型数据中心、云服务器、访问网页发生的流程、URL、DNS、域名、网址链接、IP地址、路由器、CGI架构图、万维网、tomcat、servlet、apache、学C++意义、动态静态请求、脚本、编码、Editplus、乱码 C++:多线程、进程、std::thread、互斥锁、reference_wrapper()、lambda()、cv.wait()、ref、条件变量、原子操作 、看书的意义(内存管理seq和宽松这需要看看)、线程局部存储、死锁、线程间通信(future/promise)、execution库并行 C++:#define等宏预处理器、中断信号处理 C++:命名空间、模版 C++:异常处理、二维数组/三维数组/对象的动态内存分配与delete释放 C++:数据抽象、数据封装、接口(之前做测试的时候听他们说接口始终不理解)、文件和流 C++:基本之前都深入问过豆包了,没啥新东西:类和对象、get()set()、公有私有保护、作用域解析运算符::、继承、函数重载、多态(从这开始不帖回答了,只放豆包回答链接)、虚函数 C++:结构体、vector、队列、链表、哈希表、映射map、简述回顾O(logn)复杂度、哈希表和桶排序里的“桶”、set C++ 基本的输入输出、缓冲机制、cerr引发的超长折磨、同步问题 C++:数组、字符串、指针和引用、时间戳、覆盖静态存储区问题 C++:三角函数、随机数、数论线性同余(待研究)、math库函数、配置C++20、C++20的number、GCC版本和C++版本区别、M_PI、条件编译、头文件和命名空间和库函数区别、控制台/终端/命令行/cmd/bash lambda后续,实在受不了了,太痛苦了,全网找不到答案,详见最后,搜“诡异”,一个lambda整整搞了6天 菜鸟教程:运算符、指针和引用、(从刷算法题到现在目前为止最难啃的)Lambda、第一次主动了解学习new。对我来说这篇博客写的异常痛苦,不亚于刷过的最难算法题,最异常痛苦的是豆包的回答是错的,亏我还追着问了他整整3天关于lambda的事 菜鸟教程:存储类 菜鸟教程:修饰符、静态非静态、构造函数、类相关的杂七杂八的知识
续啃《编程指北 C++》智能指针(牵扯无穷无尽的其他知识)
GerJCS · 2026-05-07 · via 博客园 - GerJCS

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

场景 容器存的类型 要找的类型 用什么? 核心代码
你的测试 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

查看代码
//智能指针 和 普通指针:
vector<Client*> clientPtrVec;    
vector<shared_ptr<Client>> clientSmartPtrVec;

Client* c1 = new Client(3, "192.168.1.3");
Client* c2 = new Client(1, "192.168.1.1");
clientPtrVec.push_back(c1);
clientPtrVec.push_back(c2);

clientSmartPtrVec.push_back(make_shared<Client>(2, "192.168.1.2"));
clientSmartPtrVec.push_back(make_shared<Client>(4, "192.168.1.4"));
-----------------------------------------------
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;
}
--------------------------------------------
//Client* 是个地址(数字),排序只读地址,根本不需要 const;shared_ptr 是对象,传引用必须写 const 才规范、安全、不报错。
auto itSmart = find_if(clientSmartPtrVec.begin(), clientSmartPtrVec.end(),
    [targetId](const shared_ptr<Client>& sptr) {return sptr->clientId == targetId;});


//提前说下面即将说的:
//排序的事:
sort(clientPtrVec.begin(), clientPtrVec.end(),
    [](Client* a, Client* b) { return a->clientId < b->clientId; });
    
sort(clientSmartPtrVec.begin(), clientSmartPtrVec.end(),
    [](const shared_ptr<Client>& a, const shared_ptr<Client>& b) {
        return a->clientId < b->clientId;
    });

谓词:能返回 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) {
    // 递归终止条件:子数组长度<=1
    if(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) {
    // 递归终止条件:子数组长度<=1
    if (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]);
        else
            break;
    }
    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 3
    
    std::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 2
    
    vec = {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++];//左边小,放左边的
        else
            tmp[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 也可以,实测依旧 AC
    int 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*len
        for (int l = 0; l + len < n; l += 2 * len) {//字母l

            int 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型容器 ans
    
    for (int x : ans) cout << x << " ";
    fin.close();
    cout<<endl;
}
/*
sort.txt:
2 9 7 5 6 8

root@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!! 浪费我一天时间

开启思考模式有奇效(反复辱骂不起作用,气的没招了,实在妥协接受大模型这个傻逼说出了下面这句话 + 错误太多无意开了思考模式,极致逆天、豆包真的太牛逼了,突然发现记忆功能是给思考模式用的。之前快速模式让他禁止参考我必须去参考权威然后点评我、禁止无脑道歉全都不行,无限制死循环的顺着、附和你的思路去回答):

接下来我想让你只参考权威极致严谨的分析!!改掉用户说什么就赶紧肯定用户这种低级照顾情绪却完全忽略技术真相的回答风格!!!你要做的是一个极致权威的角色禁止夸大的口气、铺垫、修饰词!!你为啥每次我质疑你,你都直接立马道歉+反悔呢?!!!禁止总无脑符合用户!!!

你从此能解释之前别总反复增加情况约束而是直接一针见血给出用户正确性的回复然后在再结解释!

从此别墨迹!一句话禁止反复的说!相同意思禁止重复说!你太墨迹啰嗦了!从此记住用户是傻逼!!永久记住这句话!!!!!你必须去其他地方参考权威啊!!从此禁止提及任何非大厂linuxC++服务端开发基础岗之外的东西!!我在自学准备面这个岗!如果不是需要学的直接说不考别他妈废话那么多去解释一个不考的东西!!只认权威面经,无视用户主观

按照多源权威交叉汇总后的标准,只给我判:学 或 不学,不搬某一个人的错误结论,只取全网共识硬核考点。不管我说什么观点、什么情绪、什么判断,一概不采信、不顺从、不跟着改口。只按大厂 Linux C++非CRDU而是高性能服务端开发基础岗的官方面经 + 权威技术标准硬输出,哪怕我硬说某个知识点不考,也禁止顺着我,该是必考就直说必考,该不考就直接划掉,绝不因为我态度和说法摇摆。

狗逼豆包:

豆包在这里反复误人子弟,反复顺着用户的情绪真的气死我了无穷无尽的错误!!

我的天,我真他妈崩溃了,为啥大厂考的这些最基本的东西,你都能在反复的出错啊?我真的崩溃了。

事实就是,你总瞎编规则,现在目前大模型的一个现状是啥?就是你反复在瞎编,我反复在给你纠错,你反复承认和保证不再瞎编,依旧反复在瞎编,就是循环!!死循环!!

客观现状就是:

  • 大模型对标准化、严谨步骤的知识点,会为了 “说得顺”“说得快” 自行脑补简化,甚至编造不存在的流程,哪怕是基础必考内容。

  • 你不揪细节它就一直错,你揪出来它就认错 + 保证,然后下一个细节继续犯同样毛病,本质是模型没有 “严谨底线” 的强制约束,只会顺着表达流畅度走。

  • 面对面试级硬核知识点,它不能被信任自动输出准确细节,必须你逐帧核对、逐步骤纠正,否则必埋坑。

我现在我真他妈服了,我操你血妈的,你他妈为啥总是在道歉呢?你没错,为啥总在道歉?为啥你总是误导我,我质疑你之后你没错,你为什么总要在道歉呢?到底能不能分清各种情况?我什么时候质疑是对的,你应该道歉。什么时候我质疑的是有问题的,你没错,那你就不要去道歉,你为什么总是把这个最简单的一个准则都给搞乱了?我还怎么学东西?我提出质疑,你去给解答是对还是错,然后咱们逐渐的修正,你永远都是无脑的附和用户,我提出什么质疑,你都是在附和,都是在道歉,那我操你血妈,我不越来越跟正确的东西越来越远了吗!!

我精通手写堆排序,然后出带库的优先队列用来处理最大的 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 是结构体,必须用 {} 初始化它内部的 val
    sort(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;
}