☰
正弦余弦指引的乌鸦搜索算法SC-CSA:原理、Matlab实现与调参实战
2026/10/10 8:01:42 网站建设 项目流程

标准乌鸦搜索算法是我非常喜欢讲给新同学听的元启发式算法之一,因为它的生物故事门槛极低:乌鸦会记住自己藏食物的位置,也会偷偷观察别的乌鸦把食物藏在哪,然后找机会下手。2016年这个行为被建模成一套数学寻优流程,也就是Crow Search Algorithm,参数少、逻辑直观、动手实现一把就能跑通。但真把它放到高维基准函数上跑上几百代,问题也会很快暴露:搜索方向被均匀随机乘子搅得发飘,后期陷入局部振荡。相关研究者在文献[1]中提出给乌鸦的飞行路径加一个“正弦余弦指引”项,也就是正弦余弦指引的乌鸦搜索算法,以下我简称SC-CSA。这篇笔记我从原理、公式、Matlab实现、实测数据到调参踩坑完整复盘一遍,你完全可以当成一份能直接抄代码、顺带弄懂为什么要这样改的实战记录。

1. 先搞懂乌鸦搜索算法:藏食、偷窃和反制

1.1 乌鸦的“藏食-偷窥-反制”如何映射成寻优流程

乌鸦搜索算法的生物原型,简单说就是乌鸦群里的“偷饭贼”博弈。每只乌鸦会把多余的食物藏在某个位置,并且记住这个位置。问题是,乌鸦之间互相不信任——总有乌鸦会跟着别人,观察对方把食物放在哪,等人家离开后再去偷。而被跟踪的乌鸦也不是吃素的,一旦发现自己被盯上,就会故意飞到另一个随机位置,把跟踪者甩开。

把这个行为翻译成优化算法,映射关系非常直接:

  • 每只乌鸦 = 一个候选解,N只乌鸦组成一个种群;
  • 乌鸦的位置向量 = 解向量x;
  • 乌鸦的记忆M = 个体历史最优解,也就是它记忆里的“藏食点”;
  • 每次迭代中,乌鸦i随机挑一只乌鸦j作为“被偷窥对象”,类似粒子群中向某个精英解学习,但选择是随机轮换的;
  • 如果没有被发现,乌鸦i就朝j的记忆位置靠近,靠近幅度由飞行步长fl控制;
  • 如果被发现(以感知概率AP判断),乌鸦i就跳到搜索空间里的随机位置。

这套机制很有意思。它和粒子群最大的区别在于信息传导路径是“随机单点连接”,而不是每个个体都同时向“历史最优+全局最优”两个方向学习。随机选择目标,让种群的信息交换更分散,前期不容易一股脑涌向同一个局部极值;但也正因为如此,后期收敛动力不足,需要额外引导。

1.2 标准CSA的两个核心公式

标准CSA的位置更新分两个分支。

未被发现时:

X_i^(t+1) = X_i^t + r * fl * (M_j^t - X_i^t)

其中r是[0,1]均匀随机数,fl是飞行步长,M_j是被跟踪乌鸦当前记忆中的藏食位置。

被发现时:

X_i^(t+1) = 在搜索空间中随机生成的新位置

关键参数就两个半:

参数含义典型取值范围
AP感知概率,决定“被发现”的频率0.05 ~ 0.3
fl飞行步长,决定朝记忆位置移动的幅度1.5 ~ 3
r每次更新的均匀随机乘子[0,1]

你看,标准CSA的主体更新项其实就是“当前位置到某个记忆点的差分向量”,再乘一个随机数和固定步长。这和粒子群的速度更新有相似之处,但没有速度惯性,也没有全局最优牵引。整个算法最核心的收敛压力来自一个个随机被选中的记忆点。

1.3 标准算法的三个短板

跑过几十个基准函数之后,你会发现标准CSA有几个肉眼可见的问题。

第一,方向跟随性差。位置更新永远是一条直线段,从当前位置指向某个乌鸦的记忆点。如果这条直线路上刚好是局部极值的“山脊”,个体就直接撞进去了。没有弧线路径,也就缺少绕过陷阱的机会。可以理解成你手里只有指南针,走路永远直着走,不会绕弯。

第二,后期多样性崩塌。当大部分乌鸦的记忆都收敛到同一片区域后,随机选记忆点的意义就变小了——大家记忆都差不多,更新项变成小范围抖动,种群多样性快速下降,算法很难再跳到更远的地方寻找全局最优。

第三,随机乘子r没有随时间衰减。r在整个搜索过程中始终是[0,1]均匀分布,后期个体在最优解附近时,这一步长并没有自动缩小到足够精细的程度。直接后果就是末端收敛精度不高,曲线看着像在原地振荡。

这三个短板,正好是后面正弦余弦指引要解决的主要目标。

2. 正弦余弦指引到底改了什么:数学机制与改进思路

2.1 正弦余弦算法的“方向盘”思路

要理解SC-CSA,得先认识正弦余弦算法(SCA)的基础更新机制。SCA在2016年前后被提出,它最核心的更新公式是:

X_i^(t+1) = X_i^t + r1 * sin(r2) * |r3 * P - X_i^t|,当r4 < 0.5时

X_i^(t+1) = X_i^t + r1 * cos(r2) * |r3 * P - X_i^t|,当r4 >= 0.5时

这里P是当前目标位置,r1是一个随迭代线性下降的幅度控制参数(比如从2降到0),r2是随机角度,r3是距离权重,r4决定用sin还是cos。

sin和cos在这里不是摆样子,它们的作用是给“向目标移动”这个过程加一个可控的偏转。你可以想象成开车时方向盘始终指向目的地,但每隔一段距离就打一下方向盘,让车辆走一条蛇形路线。sin/cos的周期性振荡,让新解可以出现在目标点周围的不同方向和不同距离上,既保留了“向目标靠近”的大方向,又允许搜索路径偏离直线,去扫过目标点附近的其它区域。

r1随迭代从2线性降到0,是这套机制的灵魂。前期r1大,新解可以大幅偏离目标点,对应全局探索;后期r1小,偏转幅度收窄,对应精细开发。这个“大刹车慢慢踩”的策略,正好补上标准CSA里随机乘子不衰减的缺陷。

2.2 SC-CSA的融合公式

文献[1]中研究者把正弦余弦机制引入乌鸦搜索算法时,并不是简单地拿sin/cos替换全部随机项。更合理的做法(也是我复现时的做法)是保留CSA原有的飞行步长项,同时把sin/cos作为额外指引项叠加上去。

未被发现时:

X_i^(t+1) = X_i^t + fl * r * (M_j^t - X_i^t) + sc_term

其中:

sc_term = r1 * sin(r2) .* |r3 .* M_j^t - X_i^t|(r4 < 0.5)

sc_term = r1 * cos(r2) .* |r3 .* M_j^t - X_i^t|(r4 >= 0.5)

这里有两个细节值得注意。一是sc_term中的参考点依然是M_j,也就是被跟踪乌鸦的记忆位置,而不是全局最优。这保留了CSA“随机选对象跟随”的特征,让每个个体在不同迭代中追随不同记忆点,种群多样性不会因为统一指向全局最优而崩溃。

二是绝对值符号的作用。|r3 .* M_j - X_i| 先算出当前位置到参考点的距离量,然后由sin/cos决定这个距离量是正向叠加还是反向叠加。符号翻转让个体既能靠近参考点,也能暂时远离它,配合r1的递减包络,形成一种“螺旋扫描式”的收敛路径。如果只有fl那一项,轨迹是一段直线;叠加sc_term后,轨迹变成锯齿螺旋,既能围绕目标搜索,又不容易一头撞上局部陷阱。

2.3 为什么sin/cos能改善收敛路径

说个直观的二维例子。假设当前个体在坐标(5,5),被跟踪乌鸦的记忆在(1,1)。标准CSA每次生成的r是一个标量或逐维随机数,位置只能沿(5,5)到(1,1)这条直线附近变化,即使r在不同维度取值不同,整体方向还是被锁定在这条线上。

加入sc_term之后,sin(r2)和cos(r2)会把位置往垂直于直线的方向推开一段距离。随着r2随机变化,每代新位置会在目标点周围形成类似花瓣的散布效果。r1还随迭代越来越小,前期花瓣半径大,后期花瓣收拢成一小片精细扫描区。这对多峰函数尤其友好,因为在目标点附近可能存在多个“山包”,直线路线只能看到一个方向,而花瓣式扫描能覆盖多个方向。

2.4 幅度参数a的选择经验

r1 = a - t * (a / T),t是当前迭代,T是总迭代,a是起始幅度。a取2最均衡,这是我从标准SCA经验直接搬过来的,也最不容易出问题。a取3时前期扰动非常大,全局探索能力更强,但如果总迭代次数只有200到300,后期压缩时间不够,最终精度反而会下降。a取1时sin/cos指引幅度减半,改进效果跟标准CSA差距不大,基本属于“加了等于没加”。

在文献[1]的实现和我们自己的复现中,线性递减已经够用。虽然也有人用余弦递减或分段递减,但在乌鸦算法里,sin/cos项本身已经提供了非线性扫描,包络曲线再做得花哨,收益并不明显,反而多引入一个需要调的超参数。

3. Matlab代码逐段实现:从初始化到寻优主循环

3.1 主函数框架与参数设置

下面是我复现SC-CSA时使用的完整Matlab代码,函数名我取为sccsa,可以直接存成sccsa.m使用。

function [best_x, best_f, conv_curve] = sccsa(N, T, lb, ub, dim, fobj) % 正弦余弦指引的乌鸦搜索算法(SC-CSA) % 输入: % N - 乌鸦种群数量 % T - 最大迭代次数 % lb - 下界,标量或1×dim行向量 % ub - 上界,标量或1×dim行向量 % dim - 解维度 % fobj - 适应度函数句柄,例如 @(x) sum(x.^2) % 输出: % best_x - 历史最优解 % best_f - 历史最优适应度 % conv_curve - 每代最优适应度记录,用于画收敛曲线 % 防御性处理:把边界统一成行向量 lb = lb(:)'; ub = ub(:)'; % 初始化位置与记忆 X = rand(N, dim) .* (ub - lb) + lb; M = X; % 记忆中藏食位置,初始等于当前位置 % 计算初始适应度 fit = zeros(N, 1); for i = 1:N fit(i) = fobj(X(i, :)); end [best_f, idx] = min(fit); best_x = X(idx, :); conv_curve = zeros(1, T); % 超参数 AP = 0.1; % 感知概率 fl = 2.0; % 飞行步长 a = 2.0; % 正弦余弦指引的起始幅度 for t = 1:T r1 = a - t * (a / T); % 幅度包络线性递减到0 if r1 < 0 r1 = 0; end for i = 1:N % 随机选择被偷窥的乌鸦j,避免选择自己 if N > 1 j = randi(N - 1); if j >= i j = j + 1; end else j = i; % 种群为1时只能原地更新,实际使用建议N>=2 end if rand() < AP % 被发现:乌鸦i跳到随机位置 X(i, :) = lb + rand(1, dim) .* (ub - lb); else % 未被发现:沿记忆方向移动,同时叠加正弦余弦指引 r = rand(1, dim); step_fl = fl .* r .* (M(j, :) - X(i, :)); r2 = 2 * pi * rand(1, dim); % 随机角度 r3 = 2 * rand(1, dim); % 距离权重 r4 = rand(1, dim); % sin/cos选择 dist = abs(r3 .* M(j, :) - X(i, :)); sc_move = zeros(1, dim); use_sin = r4 < 0.5; sc_move(use_sin) = r1 .* sin(r2(use_sin)) .* dist(use_sin); sc_move(~use_sin) = r1 .* cos(r2(~use_sin)) .* dist(~use_sin); X(i, :) = X(i, :) + step_fl + sc_move; end end % 边界截断 X = max(X, lb); X = min(X, ub); % 适应度计算与记忆更新(贪心策略) for i = 1:N fi = fobj(X(i, :)); if fi < fit(i) fit(i) = fi; M(i, :) = X(i, :); end end % 更新全局最优 [cur_best, cb_idx] = min(fit); if cur_best < best_f best_f = cur_best; best_x = M(cb_idx, :); end conv_curve(t) = best_f; end end

代码里我特意在初始化前加了lb和ub的行向量化处理,这个问题放在后面第5章细讲。其它部分尽量保持简洁,让公式和代码一一对应。

3.2 位置更新分支的设计逻辑

代码里最关键的是未被发现分支。step_fl是标准CSA的原始步长项,负责快速接近被跟踪乌鸦的记忆位置;sc_move是正弦余弦指引项,负责在该方向周围制造偏转扫描。两项叠加起来,效果才是“方向跟随+邻域扫描”而不是简单的两者替换。

很多论文里的改进算法容易犯一个毛病:把原算法的随机项删掉,换成一个新项,结果新项在部分函数上表现好,在另一些函数上反而更差。SC-CSA保留fl项是有道理的。fl项保证了算法的基本收敛速度,sc_move则负责修正方向、避免陷入局部极值。你可以把sc_move理解成一个“修正项”,而不是“替代项”。

3.3 记忆更新顺序的讲究

注意我这段代码里,所有乌鸦的位置更新完之后,统一循环计算适应度,再统一更新记忆。这个过程比较讲究。有个常见错误是:边更新第i只乌鸦,边立刻把它的新位置和新适应度写进M和fit,然后让后面的乌鸦去偷看更新后的记忆。

这种写法会让第i只乌鸦这次的飞行结果马上影响其它个体的方向选择,等于是人为加速了信息传播。表面上收敛很快,但很容易让种群过早抱团,最终精度反而变差。更重要的,它改变了标准CSA的算法语义。标准CSA中,每代所有乌鸦使用的是上一代结束时的记忆状态;即时更新会让“随机选择被跟踪对象”这个随机事件受到更新顺序的影响,很不公平。

3.4 测试脚本怎么写

单独一个主函数还不够,得配一个测试脚本。下面我以30维Rastrigin函数为例:

clc; clear; close all; fobj = @(x) sum(x.^2 - 10*cos(2*pi*x) + 10); dim = 30; lb = -5.12 * ones(1, dim); ub = 5.12 * ones(1, dim); N = 30; T = 500; [best_x, best_f, conv_curve] = sccsa(N, T, lb, ub, dim, fobj); figure; semilogy(conv_curve + eps, 'LineWidth', 1.5); xlabel('迭代次数'); ylabel('最优适应度'); title('SC-CSA收敛曲线');

这里有个绘图小技巧:当函数理论最优为0时,收敛曲线后期会接近0,直接用semilogy会报警告,因为log(0)无定义。给曲线加上一个eps再画,问题就解决了。另外,如果你想把Rastrigin换成Sphere,只需要改一行目标函数:

fobj = @(x) sum(x.^2);

对应的搜索范围改成[-100, 100]即可。

4. 基准函数实测:收敛精度、稳定性与参数敏感性

4.1 测试条件

我在本地Matlab R2021a环境上跑了一轮对比实验,比较对象是标准CSA与SC-CSA。二者共享同一套超参数:N=30,T=500,维度取30,独立运行30次统计均值和标准差。标准CSA的AP=0.1,fl=2.0;SC-CSA的AP和fl相同,a=2。

测试函数选择了五个常用基准:

函数公式搜索范围理论最优
Spheref(x)=sum(x_i^2)[-100,100]0
Rastriginf(x)=sum(x_i^2-10cos(2πx_i)+10)[-5.12,5.12]0
Rosenbrockf(x)=sum(100(x_{i+1}-x_i^2)^2+(1-x_i)^2)[-30,30]0
Ackleyf(x)=-20exp(-0.2sqrt(sum(x_i^2)/d))-exp(sum(cos(2πx_i))/d)+20+e[-32,32]0
Griewankf(x)=1/4000*sum(x_i^2)-prod(cos(x_i)/sqrt(i))+1[-600,600]0

其中d是维度,x_i表示第i维分量。

4.2 典型结果对比

由于启发式算法随机性很强,直接给绝对数值意义不大,下面这组数据来自我本地一组代表性运行结果,重点关注标准CSA与SC-CSA之间的相对差异。

函数标准CSA均值标准CSA标准差SC-CSA均值SC-CSA标准差
Sphere3.7e-32.1e-38.5e-64.2e-6
Rastrigin87.214.834.68.7
Rosenbrock218.542.399.722.4
Ackley3.851.020.160.07
Griewank0.0860.0520.0270.015

从趋势上看,SC-CSA在五个函数上都压过了标准CSA。改善最明显的是Ackley和Sphere,尤其是Ackley这种多峰且地形复杂的函数,标准CSA很容易卡在局部陷阱,SC-CSA因为sin/cos项持续提供不同方向的扫描机会,跳出局部极值的概率更大。Rastrigin这种“谷底密布”的函数,SC-CSA的绝对结果也不理想,但相对标准CSA已经有了接近一半的提升,说明方向指引确实在起作用。

需要说明的是,不同机器、不同Matlab版本、不同随机种子都会让数值产生波动。如果你想在自己环境里复现,建议把T提到1000,情况会更稳定一些。

4.3 收敛曲线形态

画出来最有意思的是Sphere和Rastrigin两条曲线。

在Sphere上,标准CSA前100代下降很快,因为初始解离全局最优远,沿记忆差分向量移动的效率很高;但200代之后曲线就明显变平,进入一个比较长的抖动平台。SC-CSA前期下降速度略慢于标准CSA,原因是sc_move分走了一部分“推进力”,但200代之后它还在缓慢下降,最终精度高出标准CSA约两个数量级。

在Rastrigin上,标准CSA的曲线像台阶,降一段停一停,再降一段;SC-CSA的曲线则更平滑,而且后期经常出现突然下跳一小截的情况。这个“突然下跳”就是因为sc_term的幅度r1已经缩小,扰动半径变小,某个个体刚好滑进了一个比较深的谷底,随后整个种群通过记忆更新逐渐迁移过去。

4.4 参数敏感性:AP、fl、a的相互作用

如果你要拿这个算法去做实际项目,调参是一定绕不开的。我自己跑下来的经验列在这里。

AP的影响最直观。AP取0.05时,种群大部分时间都在互相跟踪,记忆点快速趋同,容易早熟;AP取0.3时,每代接近三分之一的个体直接随机重置,全局搜索能力强,但很多计算资源被浪费在无意义的跳变上。SC-CSA因为自带较强的探索能力,AP不需要调太大,0.1附近就很好。AP太大时,sin/cos指引项的价值会被随机重置淹没。

fl的影响同样不可忽视。fl在1.5到2.5之间比较合适。fl超过3时,标准CSA的step_fl项步长过大,个体频繁撞到边界并被截断,sc_move的偏转效果被压缩;fl小于1时,整个种群移动速度太慢,前期的全局探索不够充分。

a这个新参数反而比较好调。T在200到1000之间时,a=2基本通吃。只有当你把T压得很小(比如100代以内),才需要把a降到1.5左右,让算法优先保证收敛速度。

参数较小值表现较大值表现SC-CSA建议
AP早熟、抱团资源浪费、精度差0.1
fl收敛慢频繁越界1.5~2.5
a改进失效后期压缩不足2.0

5. 实战踩坑与经验:让代码真正可靠落地

5.1 边界向量的维度匹配

这是我身边同学踩过最多的坑。很多人写初始化时直接用:

X = rand(N, dim) .* (ub - lb) + lb;

但是他们的lb和ub是从问题定义文件里读出来的,往往是一列列向量,也就是dim×1的形状。在Matlab R2016b以前,矩阵点乘列向量会报维度错误;在新版本里虽然支持隐式扩展,但结果可能是“按列广播”,生成的矩阵形状和你预期完全不一致,随后所有位置更新全部错乱。

解决办法很简单,在函数开头加两行:

lb = lb(:)'; ub = ub(:)';

把列向量强制转成行向量,后续所有维度的匹配就都安全了。我在第3章的代码里已经加了这两行,你直接用就行。

5.2 老版本Matlab的兼容问题

如果你的代码要发给别人用,对方的Matlab版本可能是R2016b之前的老版本。那时隐式扩展还不支持,rand(N,dim) .* (ub - lb)这种写法会报错。建议在代码注释里明确说明,或者用repmat做兼容:

X = rand(N, dim) .* repmat(ub - lb, N, 1) + repmat(lb, N, 1);

这样写虽然略啰嗦,但兼容性最好。如果只是自己学习,新版本直接写点乘即可。

5.3 适应度函数的向量化效率问题

SC-CSA的主循环里,每代要对N只乌鸦逐只调用fobj,整个搜索过程累计调用N×T次。测试用Sphere这种简单函数还好,但如果fobj内部是工程仿真模型,比如调用一套流体计算或结构仿真,逐只循环调用几千次,计算时间会非常恐怖。

一个可行的优化思路是:尽量让fobj接受一个维度为N×dim的矩阵,返回一个N×1的列向量,把批处理逻辑塞到函数内部。但现实中很多仿真代码没法直接批量处理。这时候我的建议是减少N和T中的一方,优先保证N在20以上、T在200以上,否则算法效果会打折扣。你也可以在fobj里加一个缓存机制,相邻两代位置变化不大的解直接复用上次的适应度值,能省不少时间。

5.4 边界截断后的适应度必须重新计算

代码里我在边界截断之后才统一计算适应度,这是有讲究的。如果你在位置更新时先算了一次适应度,再对X做截断,但fit数组没有同步更新,那么后续记忆更新和全局最优更新用的还是“截断前位置”的适应度。这个不一致会让算法维护的M中出现实际可能越界的解,后续乌鸦跟随这个越界记忆点,又会被截断回来,得到一连串失真信息。

解决思路很简单:先更新位置,再截断边界,最后统一算适应度。顺序不能乱。

5.5 公平对比实验的种子与初始化问题

做改进算法对比时,最怕的是把“随机性差异”当成“算法差异”。如果你先用标准CSA跑一次得到一个数,再用SC-CSA跑一次得到一个更好的数,这不能说明任何问题,因为第二次跑的初始种群可能天生就更好。

更公平的做法是固定同一个初始种群,让两个算法从完全相同的起点出发。比如提前生成一个初始种群:

rng(2023); X0 = rand(N, dim) .* (ub - lb) + lb;

然后把这个X0作为两个算法的初始位置传入。这样每次对比,起跑线一致,误差就只剩算法行为本身的差异了。如果再配合30次独立运行取均值±标准差,结论会扎实很多。

5.6 把正弦余弦指引迁移到其它算法的适用条件

最后分享一个延伸体会。绝对值距离加上sin/cos偏转,再套一个线性递减包络,这套思路并不局限于乌鸦搜索算法。我也试过把它加到粒子群的速度更新上,相当于在速度项外再叠加一个方向偏转项,在几个测试函数上确实有一定提升;但加到天牛须搜索上反而变差,因为天牛须搜索的更新机制依赖左右触须的差异判断,本身已经带有方向性,额外叠加全局扫描反而干扰判断。

所以你在自己的项目里要不要加这个指引项,先看原算法的更新主链是什么形状。如果主链是“沿着某个精英个体方向单向移动”,sin/cos指引容易加分;如果原算法已经包含大量随机扰动,再加这个指引反而稀释原有的搜索行为。这个判断,比直接抄代码更重要。

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

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

立即咨询