模拟退火入门到入土

模拟退火从入门提高+省选-

前言

随着比赛越打越多,我逐渐认识到自己的不足,对与一些题目并不能想到正解,或正解过于复杂而无法码出,每次的成绩都不太理想,因此在机缘巧合下我结识了模拟退火这一本质上是暴力的随机化算法,因此我在这里讲解一下这几天我刷的题目以及我的感悟。囊的过分的bossaqzjklo

正文

如果不了解模拟退火建议看一下这个:

模拟退火是一个极其玄学通用的算法,在题目中虽不能保证拿满分但部分分至少是有的,适用于想不出正解却还想得分的人(比如我)

模拟退火的原理是源自金属退火,金属的温度不断下降,逐渐冷却。


你初始有一个温度值 \(T_{1}\) ,建议为一个较大值,和一个最终温度值 \(T_{0}\) ,建议为一个略大于0的数,还有一个降温系数 \(s\) 为一个略小于1的数。

最初你将 \(T=T_{1}\) ,接着随机寻找一个数字 \(begin\) ,将他视为当前最优解,每次查找都在一定范围内再随机寻找一个数 \(now\) ,并计算他们的差值 \(\Delta=f(now)-f(begin)\) ,显然有两种情况 \(\Delta\leq0\)\(\Delta>0\) ,第一种情况很好想,肯定是当前的数比之前的好,直接转移就好了,但是我们现在要重点考虑第二种情况,直接搬公式:

\[P=\left\{\begin{array}{ll} 1 & \Delta \leq 0 \\ e^{-\Delta / T} & \Delta >0 \end{array}\right. \]

\(P\) 是啥?是我们选这个数的概率,简单来说就是当它大于零的时候肯定是没有之前的解优秀的,但我们还是要以 \(P\) 概率来选取,因为这样可以扩展我们查找的范围最后能找出查找更优解,至于为什么长这样接下来再解释。最后再将 \(T=Ts\) ,使温度下降。整个模拟过程如图:

simulated annealing

可以看到,随着温度的降低,答案越来越难以被更改,最后的答案基本是最优解。


至于为什么要以 \(e^{-\Delta/T}\) ,为概率呢?

  • \(e^{-x}\) 可以保证 \(P\in[0,1]\)
  • 随着 \(T\) 的增加 \(P\) 也会增加,越来越难以接受新答案,答案越稳定。

因此使用上述表达式是应该比较合适的,当然如果你有更好的公式也是可以的。

例题

区间最大值

题意

给定 \(n\) 个正整数,求出其中的最大值。

code

#include <bits/stdc++.h>
using namespace std;
int n,a[10005],tans,bans;
double bt=1000,et=1e-5,lt=0.995;//bt为初始温度,et为结束温度,lt为温度系数
int main()
{
	srand(time(0));
	srand(rand());
	cin>>n;
	for (int i=1; i<=n; i++)
	{
		cin>>a[i];
		tans=max(a[i],tans);
	}
	int ans=rand()%n+1;
	int na=a[ans];//初始随机选择一个数作为最优解
	while (bt>et)
	{
		int g=rand()%(min(ans+int(bt)*100,n)-max(ans-int(bt)*100,1)+1)+max(ans-int(bt)*100,1);//根据当前温度来选择合适的遍历范围
		int de=na-a[g];
		if (de<=0)//新的解更优直接更新
		{
			bans=a[g];
			na=a[g];
			ans=g;
		}
		else if(double(rand())<exp(double(-de)/bt)*RAND_MAX) //差的解也有一定概率接受
		{
			na=a[g];
			ans=g;
		}
		bt*=lt;//退火
	}
	cout<<tans<<" "<<bans;
}

Haywire

P2210 Haywire - 洛谷

题意

每个奶牛都有3个朋友,朋友之间必须要连接奶牛,可以放在不同位置,求连接的最小价值。

思路

非常板的一道模拟退火题,先按照原始序列打一遍价值,接着每次模拟退火都随机交换两个数的位置,再套用公式有概率选取就完成了呢。

#include <bits/stdc++.h>
using namespace std;
int n,a[15][5],ans=0x7f7f7f7f,v[15],vv[15];
const double bt=10000,et=1e-10,lt=0.99,tim=0.997;
int work()
{
	int nans=0;
	for (int i=1;i<=n; i++)
	{
		nans+=abs(v[a[i][1]]-v[i])+abs(v[a[i][2]]-v[i])+abs(v[a[i][3]]-v[i]);
	}
	return nans/2;
}
int main()
{
	srand(time(0));
	cin>>n;
	for (int i=1; i<=n ;i++)
	{
		cin>>a[i][1]>>a[i][2]>>a[i][3];
		v[i]=i;
	}
	 ans=work();	
	while (clock()/(1.0*CLOCKS_PER_SEC)<=tim)//卡时
	{
		int t=bt;//设置初温
		while (t>et)//SA
		{
			int x,y;
			do
			{
				x=rand()%n+1,y=rand()%n+1;
			} while (x==y);//必须要两个数不相等才行,如果相等就没意义了呢
			swap(v[x],v[y]);
			int wans=work();
			int de=wans-ans;
			if (de<0)
			{
				ans=wans;
			}
			else if (double(rand())>exp(double(-de)/bt)*RAND_MAX)//公式
			{
				swap(v[x],v[y]);//很抱歉,你不仅实力不行,脸还黑,那只能换回去了
			}
			t*=lt;//降温ing
		}
	}
	cout<<ans;
} 

平衡点 / 吊打XXX

P1337 JSOI2004\平衡点 / 吊打XXX - 洛谷

题意

在平面直角坐标系内,给你一些点,每个点都有个点权,让你求出一个点,使这个点到所有点的距离乘权值总和最小。

思路

考虑模拟退火,我们先找一个点,这个点的位置也是有讲究的,我们可以求所有点的平均数,以这个点为初始答案后进行模拟退火,每次模拟退火都根据当前的温度进行微扰,多打几次,多调调参就好了。

code

#include <bits/stdc++.h>
using namespace std;
int n;
double x[10005],y[10005],w[10005],ansx,ansy,answ,tx,ty;
const double bt=3000,et=1e-16,lt=0.996,tim=0.98;
double work(double mx,double my)
{
	double ans=0;
	for (int i=1; i<=n; i++)
	{
		ans+=sqrt((mx-x[i])*(mx-x[i])+(my-y[i])*(my-y[i]))*w[i];
	}
	return ans;
}
int main()
{
	cin>>n;
	for (int i=1; i<=n; i++)
	{
		cin>>x[i]>>y[i]>>w[i];
		ansx+=x[i];
		ansy+=y[i];
	}
	if (n==1)
	{
		cout<<fixed<<setprecision(3)<<x[1]<<" "<<y[1];//要进行特判,防止出现找不出的情况
		return 0;
	}
	ansx/=n;
	ansy/=n;
	answ=work(ansx,ansy);//以平均点来进行初始操作
	while (clock()/(1.0*CLOCKS_PER_SEC)<=tim)
	{
		double t=bt;
		while (t>et)
		{
			double nansx=ansx+(rand()*2.0-RAND_MAX)*t;
			double nansy=ansy+(rand()*2.0-RAND_MAX)*t;
			double nansw=work(nansx,nansy);//根据权重来判断
			double de=nansw-answ;
			if (de<0)
			{
				tx=nansx;//更新答案
				ty=nansy;
				ansx=nansx;//更新坐标
				ansy=nansy;
				answ=nansw;//更新权重
			}
			else if (double(rand())/(RAND_MAX*1.0)<exp(double(-de)/t))//有概率会选
			{
				ansx=nansx;
				ansy=nansy;
			}
			t*=lt;//降温
		}
	}
	cout<<fixed<<setprecision(3)<<tx<<" "<<ty;
}

分金币

P3878 TJOI2010 分金币 - 洛谷

题意

将一堆金币分成相差至多为1的两堆,求最小价值差。

思路

考虑模拟退火,最开始贪心将两部分分成尽量相等的两堆,接着模拟退火,每次随机选取不同堆的两个点进行交换实现微扰,最后统计答案即可。

code

#include <bits/stdc++.h>
using namespace std;
int t,n,a[35];
int ans;
const double bt=5000,et=1e-10,lt=0.9112;
int work()
{
	int sum1=0;
	for (int i=1; i<=n/2; i++)
	{
		sum1+=a[i];
	}
	int sum2=0;
	for (int i=n/2+1; i<=n; i++)
	{
		sum2+=a[i];
	}
	return abs(sum1-sum2);//计算两个堆的差值
}
int main()
{
	srand(time(0));
	cin>>t;
	while (t--)
	{
		int k=1000;//执行1000次提高正确率
		cin>>n;
		for (int i=1; i<=n; i++)
		{
			cin>>a[i];
		}
		ans=work();
		while (k--)
		{
			double t=bt;
			while (t>et)
			{
				int x,y;
				x=rand()%n+1;
				y=rand()%n+1;
				swap(a[x],a[y]);//交换
				int wans=work();
				int de=wans-ans;
				if (de <0)
				{
					ans=wans;
				}
				else if (double(rand())/RAND_MAX>exp(double(-de)/t))//没实力,还没运气,不换回去干吗?
				{
					swap(a[x],a[y]);
				}
				t*=lt;//降温
			}
		}
		cout<<ans<<endl;
	}
}

均分数据

题意

给你几个数,让他们分成几组,求出每组总和的均方差最小时的均方差。

思路

考虑模拟退火,先用贪心将这些数分到当前最小的组里,在模拟退火中每次随机选取一个数和一个组,将这个数加入到这个组里。

发现我们实际上就是为了看每一组的总和,所以加入一个数的时候实际就是把这个数加入总和,不必考虑一个组是哪些数组成的。

code

#include <bits/stdc++.h>
using namespace std;
int n,m,a[25],sum[10],q[25],v[10];
double ans;
const double bt=1000,et=1e-10,lt=0.9995,tim=0.988;
double work()
{
	double pjs=0,mans=0;
	for (int i=1; i<=m;i++)
	{
		pjs+=v[i];
	}
	pjs=pjs/double(m);
	for (int i=1;i <=m; i++)
	{
		mans+=(pjs-double(v[i]))*(pjs-double(v[i]));
	}
	mans=mans/double(m);
	return mans;
}
int main()
{
	srand(time(0));
	mt19937 ran(rand()); //实测mt19937更加优秀,平均比rand高10~20分
	cin>>n>>m;
	for (int i=1; i<=n ;i++)
	{
		cin>>a[i];
	}
    for (int i=1;i <=n; i++)//贪心求近似解
    {
    	int minn=INT_MAX;
    	for (int j=1; j<=m; j++)
    	{
    		if (v[j]<minn)
    		{
    			minn=v[j];
    			q[i]=j;//记录第i个点属于哪个组
			}
		}
		v[q[i]]+=a[i];//把每个数加入他的组里
	}
	ans=work();
	while (clock()/(1.0*CLOCKS_PER_SEC)<=tim)//卡时
	{
		double t=bt;
		while (t>et)
		{
			cout<<"";//玄学代码增加正确率
			int x,y;
			do
			{
				x=ran()%n+1;
				y=ran()%m+1;
			}while (q[x]==y);
			int kl=q[x];
			v[q[x]]-=a[x];
			v[y]+=a[x];
			q[x]=y;//交换
			double nans=work();
			double de=nans-ans;
			if (de<0)
			{
				ans=nans;
			}
			else if (double(rand())/(RAND_MAX*1.0)>exp(double(-de)/bt))
			{
				q[x]=kl;//不行就交换回来
				v[q[x]]+=a[x];
				v[y]-=a[x];
			}
			t*=lt;//退火
		}
	}
	cout<<fixed<<setprecision(2)<<sqrt(ans);
}

后记

模拟退火算法在很多领域都有应用,但做为OIer,平时还是要多思考,不要老想着骗分,打模拟退火(虽然骗分很爽),那么这篇博客就此结束了...吗?

花絮

为了刷模拟退火,这几天我快疯了,模拟退火又难调又恶心,如图:

这道题我整整刷了7页,整整130+次。

这个也刷了4页。

也有可能是我太非了,总之模拟退火还是一个看脸的算法。。。

aqzjklo呜呜呜,我再也不写SA啦

posted @ 2025-07-12 17:24  aqzjklo  阅读(446)  评论(1)    收藏  举报