☰
算法学习50天:从排序、KMP到动态规划与工程算法实战总结
2026/10/23 7:49:17 网站建设 项目流程

1. 为什么叫“更弱智的算法学习”

先解释一下这个标题。day50,意思是我已经连续学了50天算法。至于“更弱智”,这算是一种自我提醒:算法这条路上,最容易犯的错就是高估自己,以为看懂了就是会了,以为能跑通就是理解了。我给自己起的这个名字,就是想每天开写之前先放低姿态,承认自己仍然很弱,然后老老实实把基础补扎实。

这个系列其实不是什么高深教程。我给自己定的要求就三条:第一,每天至少解决一个算法问题,并且能讲清楚思路;第二,每周挑一个算法做深度拆解,把原理、代码、应用场景都串起来;第三,每十天回头把容易混淆的算法做一次对比总结。50天下来,虽然离“大神”还差得远,但回头翻这些笔记,确实能看出自己哪里进步了,哪里还在原地打转。

如果你也在学算法,或者正准备开始,这篇内容会比较适合你。我不会堆一堆数学公式吓唬人,也不会只丢代码让你自己悟,而是把那些热搜词里频繁出现的算法——排序、KMP、贪心、动态规划、模拟退火、粒子群、PID——逐个聊一遍,讲讲我实际怎么理解、怎么用、踩过哪些坑。学算法这件事,最大的门槛其实不是智商,而是方法。方法对了,普通人也能扎扎实实往前走。

这个day50的笔记,记录了我在这个节点上对算法学习整体路线的反思,也包含了最近一周实际练习的几个重点题和算法实现。希望能给同样在坚持的你一点参考。

2. 50天算法路线的整体设计与思路拆解

2.1 先定框架,再填细节

前10天我基本在“乱学”:今天看到一个冒泡排序,觉得挺简单,写一遍就过;明天看到KMP,被next数组绕晕,又跳到别的。结果就是学了忘、忘了学,进度看似很快,实际上什么都没留下。

后来我换了个思路:先搭一个覆盖主要算法类型的框架,把这个框架当成一张地图。每个算法先学归类,再学高频考法,最后学工程应用。这样每天学什么、学到什么程度,都心中有数。

我的50天分成了五个阶段:

  • 第1-10天:基础数据结构与排序,数组、链表、栈、队列,以及冒泡、选择、插入、归并、快排、堆排。
  • 第11-20天:字符串与查找,重点啃KMP、二分查找、哈希表,捎带补了Trie树。
  • 第21-30天:树和图,二叉树遍历、二叉搜索树、最短路径(Dijkstra、Floyd)、拓扑排序。
  • 第31-40天:暴力搜索与优化,回溯、贪心、动态规划、剪枝,这是最痛苦也最关键的十天。
  • 第41-50天:进阶与工程方向,哈希、状态压缩、模拟退火、粒子群、蚁群,以及PID这类控制领域的算法。

回过头看,这个顺序其实暗合了算法学习的规律:先打牢数据结构底座,再学查找和排序这些基础算法,然后过渡到图论和搜索,最后才是高级优化和工程应用。心急吃不了热豆腐,顺序乱了,后面全乱。

2.2 为什么先排序后图论

排序算法之所以值得花10天专门学,是因为它几乎是所有算法的“基本功”。归并排序的思想引出了分治,堆排序引出了优先队列和堆数据结构,快速排序的partition思想被用在Top-K问题、荷兰国旗问题、快选算法里。

举个例子:你后面学Dijkstra最短路径,如果不懂优先队列(堆)是怎么维护最小值的,就很难理解为什么能每次O(log n)取出当前距离最短的节点。堆排序里那个下沉、上浮操作,其实就是优先队列的核心。所以不是排序本身有多难,而是排序算法里面藏着后续所有算法的“零件”。

图论排在第三周,是因为图论问题往往需要前面两章的基础。最短路径问题要求你理解邻接表、邻接矩阵、队列、堆,甚至动态规划思想;拓扑排序又要求你理解入度、出度、BFS。没有前面的底子,直接上手图论,很容易被各种概念砸晕。

2.3 贪心、DP、回溯和剪枝的关系

这几类算法经常被放在一起学,因为它们的核心都是“搜索解空间”,只是策略不同:

  • 回溯:把所有可能都走一遍,走不通就回头,典型代表是八皇后、全排列。
  • 贪心:每一步都选当前看起来最优的,不回头,典型代表是区间调度、最小生成树。
  • 动态规划:把大问题拆成重叠子问题,记录中间结果,避免重复计算,典型代表是背包问题、最长公共子序列。
  • 剪枝:不管哪种搜索,提前判断某条路不可能得到答案就砍掉,典型代表是数独求解、N皇后优化。

以前我总觉得动态规划高不可攀,后来发现它和暴力搜索其实是一家。暴力搜索之所以慢,是因为重复计算太多;动态规划之所以快,是因为用一个表把已经算过的结果存起来。所以学DP之前,我建议先老老实实把回溯写明白,画递归树,看重复子问题出现在哪里,然后你自然就能理解为什么要用DP数组。

3. 核心细节解析与实操要点

3.1 排序算法:不能只会写,还得会选

我见过不少人,一提排序就能写个冒泡,但问“500万条数据用什么排序”就懵了。算法学习不能停留在“能写出来”,你得知道每个排序的脾气:

算法平均时间复杂度最坏时间复杂度空间复杂度稳定性适用场景
冒泡排序O(n²)O(n²)O(1)稳定教学示范、近乎有序的短数组
选择排序O(n²)O(n²)O(1)不稳定数据量小、交换代价高时
插入排序O(n²)O(n²)O(1)稳定数据量小且基本有序,效率极高
归并排序O(n log n)O(n log n)O(n)稳定需要稳定排序、数据量大的外部排序
快速排序O(n log n)O(n²)O(log n)不稳定通用排序,工程中最常用
堆排序O(n log n)O(n log n)O(1)不稳定需要O(1)额外空间或Top-K问题

我的经验是:如果你只是写业务代码,语言自带的排序函数(比如C++的sort、Python的sorted)通常已经是最好的选择,它们的实现是混合策略,性能很好,不用担心。但如果你在面试或者刷题,还是要能手撕快排和归并。因为这不仅是考排序,还考递归、分治、指针操作这些基本功。

快排的写法,我推荐背下这段C++版本,它比网上一堆花哨写法更简洁,也更容易记:

void quickSort(vector<int>& arr, int left, int right) { if (left >= right) return; int i = left, j = right; int pivot = arr[(left + right) / 2]; while (i <= j) { while (arr[i] < pivot) i++; while (arr[j] > pivot) j--; if (i <= j) { swap(arr[i], arr[j]); i++; j--; } } quickSort(arr, left, j); quickSort(arr, i, right); }

注意,这里pivot取的是中间元素,不是第一个也不是最后一个。这样写的好处是,面对基本有序的数组,不容易退化成O(n²)。如果你取第一个元素做pivot,而数组已经有序,那每次划分都会极度不平衡,递归深度就可能变成n,甚至栈溢出。

冒泡排序虽然性能差,但我在学习初期仍然花费时间手写过。它的价值在于让你直观理解“交换相邻元素”“一轮把最大值送到末尾”这种过程。有了这个过程,后面学奇偶排序、鸡尾酒排序、以及某些链表排序时,理解成本都会降低很多。

3.2 KMP算法和next数组:别背,画出来

KMP是很多人的噩梦。我第一次学的时候,看网上的代码两个小时没看懂,后来发现关键不在代码,而在你懂不懂next数组。

next数组的语义是:对于模式串的每个位置i,它的最长相等前缀后缀长度。举个例子,模式串“ABABC”,它的前缀有A、AB、ABA、ABAB,后缀有BABC、ABC、BC、C。到第4个字符(从0开始)时,next[4]=2,因为“AB”既是前缀又是后缀。

求next数组的代码其实很短:

vector<int> buildNext(const string& p) { int m = p.size(); vector<int> next(m, 0); for (int i = 1, j = 0; i < m; i++) { while (j > 0 && p[i] != p[j]) j = next[j - 1]; if (p[i] == p[j]) j++; next[i] = j; } return next; }

这里最让人迷惑的是while循环里j = next[j - 1]。用生活化的类比:你已经匹配了j个字符,现在第j+1个字符对不上,你不能从头再来,而是回退到之前已经匹配好的“最长相等前缀”的结尾处。这个回退过程可能不止一次,所以用while。

我自己学这个算法的体会是:第一步,先在纸上用暴力匹配推一遍,定位每次失配的位置;第二步,把模式串所有前缀的前后缀列出来,手动算出next数组;第三步,再对照代码,看看j的回退和next数组的对应关系。这三步走完,KMP才算真正通了。如果只看代码,三天后必忘。

3.3 贪心算法:证明才是分水岭

贪心算法的代码通常很短,难的是证明“贪心策略是对的”。很多人学贪心时容易产生一种错觉:这道题我“感觉”每次选最大的就行。但“感觉”不等于正确,很多问题乍一看适合贪心,实际上要用动态规划。

我通常用两个标准判断:

  1. 局部最优是否能推出全局最优。满足这个,才算具备贪心选择性质。
  2. 问题是否存在重叠子问题。如果存在,且子问题会互相影响,大概率贪心不行。

举一个经典例子:活动选择问题。有n个活动,每个有开始时间和结束时间,问最多能参与多少个不冲突的活动。标准贪心策略是按结束时间排序,每次选结束最早且与前面不冲突的活动。为什么按结束时间排序而不是按开始时间或持续时间?因为结束时间越早,给后面留出的时间越多,这是直观上说得通的,而通过归纳法可以严格证明。

但换一个变体:每个活动有权重,现在要选权重和最大的无冲突集合,贪心就直接失效了。必须用动态规划,先按结束时间排序,然后对每个活动,二分找前面最后一个结束时间小于它开始时间的活动,做状态转移。

所以学贪心,我的建议是:每做一道题,别急着写代码,先把“为什么这样选是对的”写在题解旁边。哪怕写不严谨,也要尝试论证。练多了,你会有一种“贪心直觉”,看到某些关键词就知道该用什么策略。

3.4 动态规划:从递归到递推的自然过渡

动态规划是热搜词里出现频率最高的,也是我踩坑最多的。我总结的学习路径是:暴力递归 -> 带备忘录的递归(记忆化搜索) -> 改成递推DP。

以斐波那契数列为例,最笨的递归写法:

int fib(int n) { if (n <= 1) return n; return fib(n - 1) + fib(n - 2); }

这个写法的问题是有大量重复计算。fib(5)要算fib(4)和fib(3),fib(4)又去算fib(3)和fib(2),fib(3)被算了两次。时间复杂度是O(2^n),n稍大就卡死。

加一个数组缓存中间结果,就是记忆化搜索:

vector<int> memo(100, -1); int fib(int n) { if (n <= 1) return n; if (memo[n] != -1) return memo[n]; return memo[n] = fib(n - 1) + fib(n - 2); }

这已经和动态规划的核心思想一样了:用空间换时间。但面试中更喜欢让你写递推版本:

int fib(int n) { if (n <= 1) return n; vector<int> dp(n + 1, 0); dp[0] = 0; dp[1] = 1; for (int i = 2; i <= n; i++) { dp[i] = dp[i - 1] + dp[i - 2]; } return dp[n]; }

从这个例子可以看出,动态规划的核心是:找到状态定义,找到状态转移方程,找到初始条件,确定遍历顺序。四步缺一不可。

等到做背包问题、最长递增子序列、编辑距离这些问题时,难度会陡增。我的建议是准备一个“DP模板笔记本”,每道题记录四件事:状态dp[i]代表什么、转移方程、初始值、答案在哪里取。50天下来,这个本子是我最宝贵的复习资料。

3.5 剪枝算法:暴力搜索的加速器

剪枝算法不是一种独立的算法,而是一类优化技巧。它的核心是:在搜索过程中,如果一个分支已经确定不可能产生最优解或合法解,就提前终止,不再往下探索。

经典应用场景:

  • 八皇后问题:放置棋子时,检查是否同列、同对角线冲突,冲突就回溯。
  • 数独求解:每个空格只放合法数字,不合法直接跳过。
  • 搜索最小操作次数:如果当前步数已经大于已知最优解,剪掉。
  • 从n个数中选k个数使和最大:如果剩下的数全选都比当前候选小,剪掉。

剪枝的难点是找到好的“剪枝条件”。条件太松,剪不掉多少,速度提升有限;条件太紧,可能会剪掉真正的最优解,导致答案错误。

我曾经在一个“数字三角形最大路径”题里用贪心加剪枝,结果答案错了,原因就是我用了一个不严谨的剪枝条件,把一条本来能到达最大值的路径提前砍掉了。后来学乖了:剪枝条件必须要么是数学上可以证明的界,要么是合法性判定,不能用“感觉这个分支不会更好”来剪。这个教训价值很高。

4. 实操过程与核心环节实现

4.1 用C++手写归并排序

归并排序是分治思想的典型代表,它非常适合用来练习递归和数组操作。我自己的实现如下,并且在每一步都加了注释:

void merge(vector<int>& arr, int left, int mid, int right) { vector<int> temp(right - left + 1); int i = left, j = mid + 1, k = 0; while (i <= mid && j <= right) { if (arr[i] <= arr[j]) temp[k++] = arr[i++]; else temp[k++] = arr[j++]; } while (i <= mid) temp[k++] = arr[i++]; while (j <= right) temp[k++] = arr[j++]; for (int p = 0; p < temp.size(); p++) { arr[left + p] = temp[p]; } } void mergeSort(vector<int>& arr, int left, int right) { if (left >= right) return; int mid = left + (right - left) / 2; mergeSort(arr, left, mid); mergeSort(arr, mid + 1, right); merge(arr, left, mid, right); }

有几个细节需要注意:

  1. mid用left + (right - left) / 2,不用(left + right) / 2,是为了防止整形溢出。虽然现在多数题目数据量不至于溢出,但养成好习惯总没错。
  2. merge函数里用临时数组,然后再拷贝回原数组,这是归并排序空间复杂度O(n)的来源。
  3. 当左半边和右半边合并时,用<=表示相等时先取左边的,这样归并排序是稳定排序。如果你写<,相等元素的相对顺序就会变化,排序就不稳定了。

我当时用100万随机数测试,归并排序大概需要0.3秒左右,而冒泡排序在这个数据量下基本跑不完。这种性能对比能帮你建立直观感受:为什么O(n²)和O(n log n)的差距不是线性增长,而是指数级拉开的。

4.2 手写二分查找的四种边界

二分查找看起来简单,但边界条件一旦写错,就是死循环或者漏解。最经典的问题就在while循环条件是left < right还是left <= right,以及更新left和right时要不要加1减1。

我总结了一套不容易出错的写法:

// 查找第一个等于target的下标,不存在返回-1 int binarySearchFirst(const vector<int>& arr, int target) { int left = 0, right = arr.size(); while (left < right) { int mid = left + (right - left) / 2; if (arr[mid] < target) left = mid + 1; else right = mid; } if (left < arr.size() && arr[left] == target) return left; return -1; } // 查找最后一个等于target的下标,不存在返回-1 int binarySearchLast(const vector<int>& arr, int target) { int left = 0, right = arr.size(); while (left < right) { int mid = left + (right - left) / 2; if (arr[mid] <= target) left = mid + 1; else right = mid; } if (left - 1 >= 0 && arr[left - 1] == target) return left - 1; return -1; }

这套写法的核心是维护一个左闭右开区间[left, right),循环不变量是:target可能存在的区间始终是[left, right)。当arr[mid] < target,说明mid及其左边都不可能,所以left = mid + 1;否则right = mid。

初学阶段,我不建议同时记多种边界写法,只记一套左闭右开的模板就够了。等用熟了,再看其他写法也能看懂。最忌讳的是今天背一种、明天换一种,结果每次写都犹豫。

4.3 Dijkstra最短路径:理解还是背板

Dijkstra算法是图论里高频出现的算法,相关热搜词里“最短路径算法”“Dijkstra算法”都排在里面。它的核心是贪心+动态规划,每次从未确定最短距离的点中,选一个距离最小的,然后松弛它的所有出边。

用优先队列实现的Dijkstra:

vector<int> dijkstra(vector<vector<pair<int,int>>>& graph, int start) { int n = graph.size(); vector<int> dist(n, INT_MAX); dist[start] = 0; priority_queue<pair<int,int>, vector<pair<int,int>>, greater<>> pq; pq.push({0, start}); while (!pq.empty()) { auto [d, u] = pq.top(); pq.pop(); if (d > dist[u]) continue; for (auto [v, w] : graph[u]) { if (dist[u] + w < dist[v]) { dist[v] = dist[u] + w; pq.push({dist[v], v}); } } } return dist; }

这里有个细节:优先队列里pair的排序是先比较第一个元素,再比较第二个元素。所以我把距离放在first,节点编号放在second。用greater<>表示小顶堆,每次取出的都是距离最小的节点。

那个if (d > dist[u]) continue是优化的关键。因为同一个节点可能被多次加入优先队列,当我们从堆里取出它时,如果当前记录的距离已经比这个旧值更小,说明这一条是过期数据,直接跳过。没有这个判断,代码也能跑,但会重复处理大量无效节点,性能差很多。

我在实际练习中发现,Dijkstra最大的坑是“图有没有负权边”。只要存在负权边,Dijkstra就不可靠,因为负权边可能导致“先经过一个距离大的点,再多走一段反而更近”的情况,而Dijkstra一旦确定一个点的最短距离就不会再更新。遇到负权边,应该用Bellman-Ford或SPFA。这个知识点我一开始没重视,导致做带负权边的题时怎么都AC不了,排查了半天才发现是算法选错了。

4.4 从PID到MPPT:工程算法也能学明白

热搜词里出现了很多工程方向的算法,PID、MPPT、混合整数线性规划、粒子群、蚁群、模拟退火等等。这些在传统算法题里不常出现,但在实际工程项目里非常有用。

以PID为例。PID是比例-积分-微分控制器的简称,它根据目标值和当前值的误差,计算出一个控制量。公式看起来简单:

u(t) = Kp * e(t) + Ki * ∫e(t)dt + Kd * de(t)/dt

但实际调参非常考验经验。Kp调大了,系统反应快但容易震荡;Ki可以帮助消除稳态误差,但太大会引起超调;Kd可以抑制 overshoot,但对噪声敏感。

我做过一个简单的温度控制模拟,用增量式PID代码:

struct PID { double Kp, Ki, Kd; double target, lastError, integral; PID(double kp, double ki, double kd) : Kp(kp), Ki(ki), Kd(kd) { lastError = 0; integral = 0; } double update(double current, double dt) { double error = target - current; integral += error * dt; double derivative = (error - lastError) / dt; double output = Kp * error + Ki * integral + Kd * derivative; lastError = error; return output; } };

写这个代码不难,难的是调参。网上有很多所谓的“整定口诀”,比如先调P、再加I、最后加D,但实际工程中往往要反复试很多组参数。我的体会是,先让P足够大(但不震荡),再逐渐加I消除稳态误差,最后加一点点D来抑制超调。这个过程至少要做几十次仿真实验。

同理,MPPT(最大功率点跟踪)算法用于光伏系统,让太阳能板始终工作在最大功率输出点附近。常用的方法有扰动观察法和电导增量法。扰动观察法的原理是:每过一段时间给工作电压加一个小扰动,如果功率变大了,就继续往这个方向扰动;如果功率变小了,就反方向扰动。这个逻辑简单但有效,工程上很多设备用得都是它。学这类算法的时候,我发现它们和纯理论算法最大的不同是:一定有噪声,一定有延迟,一定要考虑硬件限制。书本上的完美模型,到工程里往往要打八折。

4.5 机器学习算法与搜索优化算法:换个赛道继续刷

热搜词里“机器学习算法”“深度学习算法”“强化学习算法”也出现了。我在day41到day45期间,开始接触这类数据和智能优化算法,因为传统的算法题做得再多,和工业界之间还是有一道不小的鸿沟。

比如粒子群算法(PSO),它模拟鸟群觅食的行为。每个粒子代表解空间里的一个候选解,粒子有速度和位置,每次迭代时根据个体历史最优和全局历史最优来更新速度和位置。这个算法的好处是不需要目标函数可导,对很多黑盒优化问题直接可用。

以C++实现核心更新公式为例:

struct Particle { vector<double> position; vector<double> velocity; double fitness; vector<double> pBest; double pBestFitness; }; void updateParticle(Particle& p, const vector<double>& gBest, double w, double c1, double c2) { for (size_t i = 0; i < p.position.size(); i++) { double r1 = rand() / (double)RAND_MAX; double r2 = rand() / (double)RAND_MAX; p.velocity[i] = w * p.velocity[i] + c1 * r1 * (p.pBest[i] - p.position[i]) + c2 * r2 * (gBest[i] - p.position[i]); p.position[i] += p.velocity[i]; } }

这里w是惯性权重,控制粒子保持原来速度的程度;c1是自我认知系数,c2是群体认知系数。调节这三个参数,直接影响算法的收敛速度和全局搜索能力。一般来说,w大一点有利于全局搜索,w小一点有利于局部精细搜索。很多改进算法就是在调w的衰减策略。

模拟退火算法也类似,它以一定的概率接受比当前解更差的解,从而跳出局部最优。这个“一定概率”通常用Metropolis准则:如果新解更差,概率为exp(-ΔE / T),其中T是当前温度,ΔE是目标函数差值。温度高时,接受差解的概率大,所以前期可以四处试探;温度慢慢降下来,接受差解的概率越来越小,最终收敛到一个稳定解。

这些智能优化算法在很多传统算法搞不定的问题上很有效。但它们本质上都是“启发式”,不保证找到全局最优。工程上通常要在“解的质量”和“计算时间”之间做权衡,这本身也是算法设计的一门学问。

5. 常见问题与排查技巧实录

5.1 while循环死循环:八成是边界没理清

我写过很多次死循环,最典型的就是二分查找和归并排序的合并过程。

二分查找死循环,问题往往出在left和right的更新上。比如你用left <= right的写法,更新时写成left = mid,而不是left = mid + 1,当区间缩小到两个相邻元素时,mid永远等于left,left永远不会增长,于是陷入死循环。

排查方法很简单:挑一个只有两三个元素的小测试用例,手动模拟循环过程。比如arr = [1, 3], target = 3,走一遍就能看出问题在哪里。

归并排序死循环一般不在mergeSort本身,而在merge函数里某个while条件写错。比如你写while (i < mid && j <= right),但i的终止条件应该是i <= mid,等于漏掉了中间那个元素。这种问题在数据量小的时候看不出来,数据量大的时候会莫名少元素。

5.2 KMP的next数组算错:最容易在回退时看走眼

next数组算错,常见原因是“最长相等前后缀的长度”定义没理解透。记住:这个长度不能等于整个子串的长度,必须是真前缀和真后缀。比如模式串“A”,它没有真前缀也没有真后缀,所以next[0]应该是0。有些网上的版本把next[0]设为-1,那是另一种KMP写法,两种都对,但混着看就会晕。

我建议初学者先用手算,把所有字符的next值写出来,再对照代码验证。如果代码结果和你手算的不一致,大概率是代码里j的回退逻辑没写对。这段代码需要反复理解,它是KMP的灵魂。

5.3 动态规划数组越界:初始化是最常见的锅

动态规划的边界条件非常多。最经典的锅是:dp数组长度为n,但你访问了dp[n],或者访问了dp[-1]。解决办法就是“在循环里多写几个if判断”,或者在初始化时把dp数组长度设为n+1,把下标0当成边界哨兵。

以“爬楼梯”问题为例:一次可以爬1级或2级,问爬到n级有多少种方法。定义dp[i]为爬到第i级的方法数,dp[0]=1, dp[1]=1,然后dp[i] = dp[i-1] + dp[i-2](i>=2)。这样初始化dp[1]时就不会越界。如果你一开始就定义dp[0]=0,那dp[2]就会算错,因为dp[2] = dp[1] + dp[0] = 1+0,但实际上到第2级有2种方法。

这类问题在LeetCode上有太多变体,每次做错,我都会把错误的那种初始化方式也写在题解旁边,提醒自己下次别犯。

5.4 贪心不适用时答案不对:检查是否有反例

有一次做“跳跃游戏”题目,要求判断能否从数组第一个位置跳到最后一个位置,每个元素代表最大跳跃长度。我的第一反应是贪心:每次跳最大步数。但很快发现反例:数组[3, 2, 1, 0, 4],按最大步数跳,第一步跳3到0,就停了,但实际上可以第一步只跳1步到2,再跳到4。所以贪心失败。

正确做法是动态维护一个“当前能到达的最远位置”,遍历过程中不断更新,如果某时刻i > 最远位置,说明无法继续前进。这本质上是“模拟+贪心”的思路,但和“每次跳最大步数”有本质区别。

我的经验是:只要贪心策略能被一个简单反例推翻,就赶紧换思路,不要死磕。如果能想到两个以上反例,可能这个问题的正确答案是动态规划或BFS。

5.5 递归栈溢出:数据量大时优先转递推

递归写起来爽,但深度一大就爆炸。C++默认栈空间一般只有8MB,如果递归深度超过几十万次,就会栈溢出。而Python的默认递归深度甚至只有1000层。

所以写递归前,先估算递归深度。如果深度可能超过1万,建议改成显式栈或者递推。比如树的的前序遍历,用递归可能很自然,但树是链状结构时,递归深度会达到节点数,风险很高;改成迭代栈实现,就安全了。

我一般会在代码里加一个计数器,测试时打印一下最大递归深度,这样能直观看到深度量级。如果太深,直接重构成递推版本,省得线上崩了再修。

6. 50天经验汇总与后续规划

6.1 值得保留的学习习惯

这50天,有几个习惯帮了我大忙,也推荐给你:

第一,每学一个算法,至少写三种表述:文字描述、伪代码、可运行代码。这样能逼着自己从不同角度理解一个算法,而不只是“眼熟”。

第二,每天坚持做一道“复习题”而不是“新题”。每当学新算法学到烦,我就回过头做一道排序或二分的简单题,找回手感。这种“新旧交替”的节奏,能有效避免学习疲劳。

第三,每10天做一次横向对比。比如把快排、归并、堆排放在一起,从时间复杂度、稳定性、空间复杂度、实际性能四个维度比较。这种对比表能让你对每个算法的定位非常清晰。

第四,给每道题写“坑位记录”。很多题会反复错,而且错的位置惊人地相似,比如二分边界、DP初始化、图的邻接表构建。把这些坑记录下来,下次遇到类似题目时直接翻,效率提升明显。

6.2 目前掌握的工具链

除了算法本身,我还不断优化自己的“工具链”,保证学习效率:

  • 刷题平台:主要在LeetCode和洛谷。前者适合经典题和面试题,后者适合数据结构和大数据量训练。
  • 调试工具:C++用Visual Studio Code + gdb,Python用pdb。调试不是“不会写才用”,而是“想看到中间状态”时必备的手段。尤其是在解决递归和DP问题时,用调试器观察变量变化,比printf加日志高效得多。
  • 画图工具:Excalidraw和draw.io。遇到树、图、DP状态转移,先在图上画出来,再动手写代码。算法题目里的很多坑,画出来一眼就能看到。
  • 算法可视化网站:VisuAlgo。它把排序、DFS/BFS、最短路过程做成动画,非常适合建立直觉。

6.3 未来30天的规划

50天只是一个阶段,后面我给自己排了这些内容:

  • 第51-60天:系统刷动态规划专题,从基础背包到状态压缩DP,目标是能独立解决中等难度的DP题。
  • 第61-70天:刷字符串专题,扩展KMP、manacher、AC自动机,这些是热搜词“kmp算法”后面自然延伸的方向。
  • 第71-80天:刷图论专题,重点攻最小生成树、网络流、二分图匹配,配合工程题做练习。
  • 第81-90天:复习和复盘,把前80天的笔记全部重写一遍,每个算法改成更简洁版本的笔记,并且用“费曼学习法”向自己讲解一遍。

这个规划不算激进,但足够扎实。我宁愿慢一点,也不愿意囫囵吞枣。

7. 一些想对同行者说的心里话

50天过去,我最真实的感受是:学算法没有捷径,但它也不是靠蛮力。每天一两个小时,认真写代码、画图、做对比,坚持下去,效果自然会出现。

这50天里,我崩溃过很多次。印象最深的是第17天,一个KMP问题我整整花了三个晚上,只能看别人题解,完全写不出来。后来我放下键盘,拿起纸笔,把模式串的每个前缀都画了一遍,画到第三遍的时候,突然就通了。那一刻的爽感,比打游戏赢一把强太多。

所以我特别想说:遇到卡壳,不要第一时间怀疑自己智商。大概率只是没找到合适的角度,或者基础知识点有缺漏。这时候不妨后退一步,把相关的基础内容补一补,再回来继续。算法学习是一场马拉松,一时的快慢不重要,能不能持续才重要。

最后再分享一个小技巧:我会在每个周末,把这一周学过的所有算法名字写在一张小卡片上,随机抽一个,然后凭记忆讲出它的思想,并写一段代码实现。写不出来就再学一遍。这种“随机检验法”比按顺序复习更能暴露自己的薄弱点,我强烈推荐你也试试。

day60的时候,我再来更新这个系列。如果这篇内容对你有一点帮助,那这50天就算没白费。我们一起继续。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询