一轮集训DAY6&7&8——高级搜索
主要学习高级搜索:
注:限于篇幅,部分代码食用洛谷剪贴板(但这些题的代码推荐先自己实现)。
高级搜索
其主要亮点在于运用不同的搜索策略达到减少时间复杂度的目的。
迭代加深DFS
看见类似于超过x步输出-1,就可以往迭代加深思考。
其主要板子就是:
void dfs(……,int now){
if(now>dep)return ;
……
}
//main中
while(!ans){
dep++;dfs(……,0);
}
最后的答案就是dep。
这种搜索常用的剪枝就是:
-
设计期望最优函数,也即A-Star中的估价函数,含义是在最优(理论上的)的情况下,达到最优解的步数。此时若当前步数加上这个估计步数大于了dep,直接
return;
-
搜到一个答案马上结束。这是因为迭代加深保证了所搜到的第一个答案是最小步数(类似BFS)
-
上下界剪枝
-
排除冗余
例题分析
导弹防御系统
题目简述:给定一个序列,将其拆分为尽量少的子序列,每一个子序列要么单调不降要么单调不增。
最优化问题,考虑迭代加深
可以这样考虑:设\(up,down\)数组记录每一个上升/下降子序列的末项,枚举每一个数\(a_i\)应该插入哪一个序列(当然找不到的时候也可以新开一个序列)即可。
就这么简单???——是的,就这么简单。
#include<cstring>
#include<iostream>
#include<algorithm>
using namespace std;
int n,a[105],up[105],down[105],ans;
bool dfs(int u,int _up,int _down){
if(_up+_down>ans)return false;
if(u==n)return true;
bool flag=false;
for(int i=1;i<=_up;i++){
if(up[i]<a[u]){
int t=up[i];
up[i]=a[u];
if(dfs(u+1,_up,_down))return true;
up[i]=t;
flag=true;
break;
}
}
if(!flag){
up[_up+1]=a[u];
if(dfs(u+1,_up+1,_down))return true;
}
flag=false;
for(int i=1;i<=_down;i++){
if(down[i]>a[u]){
int t=down[i];
down[i]=a[u];
if(dfs(u+1,_up,_down))return true;
down[i]=t;
flag=true;
break;
}
}
if(!flag){
down[_down+1]=a[u];
if (dfs(u+1,_up,_down+1))return true;
}
return false;
}
int main(){
ios::sync_with_stdio(false);
while(cin>>n&&n){
for(int i=0;i<n;i++)cin>>a[i];
ans=0;
while(!dfs(0,0,0))ans++;
cout<<ans<<"\n";
}
return 0;
}
巴士
可以考虑先预处理出所有可能的巴士线路,代码如下:
#define re register
#define line inline
struct node{
int s,d,siz;
bool operator<(node b){
return siz>b.siz;
}
}b[370000];
line bool check(int s,int d){
for(re int j=s;j<60;j+=d)if(!num[j])return false;
return true;
}
line void init(){
read(n);for(re int i=1;i<=n;i++){int x;read(x);num[x]++;}
for(re int i=0;i<60;i++)
for(re int j=i+1;j+i<60;j++)
if(check(i,j))b[++tot]=(node){i,j,(59-i)/j+1};
sort(b+1,b+tot+1);
}
然后,我们就食用IDDFS,在每一层枚举食用哪一个线路,并打标记。
接着,你交上了这份无比暴力的代码,发现自己仅有可怜的几分。
接着,我们考虑剪枝。
- 最优性剪枝——迭代加深在这里
- 可行性剪枝:记录下每条线路最多覆盖多少点,然后用\(sum\)统计后面最多能覆盖多少点,不够就不能选
- 优化搜索顺序:考虑优先选择覆盖点数多的线路,这样可以使得搜索树的上层分支变小,从而提升效率(有了这个,优化2的\(sum\)其实是可以不用的)。
不过我最后的代码卡卡也只有83分可恶的yzh那群人加的数据(听说全17是高分)
#define re register
#define line inline
struct node{
int s,d,siz;
bool operator<(node b){
return siz>b.siz;
}
}b[370000];
int num[100],tot,dep,n;
line bool check(int s,int d){
for(re int j=s;j<60;j+=d)if(!num[j])return false;
return true;
}
line void read(int &x){
x=0;char ch=getchar();
while(ch>'9'||ch<'0')ch=getchar();
while(ch>='0'&&ch<='9')
x=(x<<3)+(x<<1)+(ch^48),ch=getchar();
}
line void init(){
read(n);for(re int i=1;i<=n;i++){int x;read(x);num[x]++;}
for(re int i=0;i<60;i++)
for(re int j=i+1;j+i<60;j++)
if(check(i,j))b[++tot]=(node){i,j,(59-i)/j+1};
sort(b+1,b+tot+1);
}
bool dfs(int now,int cnt,int cur){
//cout<<now<<" "<<cnt<<" "<<cur<<endl;
if(now==dep){
if(cur==0){
cout<<dep;exit(0);
}
return false;
}
if(b[cnt].siz*(dep-now)<cur)return false;
for(re int i=cnt;i<=tot;++i){
node x=b[i];
if(check(x.s,x.d)){
for(re int i=x.s;i<=59;i+=x.d){
num[i]--;
cur--;
}
if(dfs(now+1,i,cur))return true;
for(re int i=x.s;i<=59;i+=x.d){
num[i]++;
cur++;
}
}
}
return false;
}
int main(){
ios::sync_with_stdio(false);init();
dep=0;
while(!dfs(0,1,n)){
++dep;
if(dep>=17)break;
}
cout<<dep<<endl;
}
Power Calculus
转化一下题意,即为:
求出一个序列:\(a_0=1,a_m=n\),对于\(\forall i\in[1,m]\),需要满足至少存在一对\(j,k\),使得\(a_i=a_j\pm a_k(j,k<i)\),最小化\(m\)。
说人话,就是对于一个首项为1末项为\(n\)的序列,要求序列的每一项都必须是前面的序列中某两项(可以重合)之和/差。
最优化的复杂搜索问题一般考虑迭代加深。
首先来证明一个性质:最优方案中:\(j,k\)中至少有一个为\(i-1\)。
证明的话,可以理解为我们是不断将某个\(x^a\)与其他数进行操作拼出\(x^n\),其中\(x^a\)始终是操作数(当然操作后就被更新了。),如果有一次没有他参与,那么如果是其余两个是和/差都可以通过同样的两次操作将\(x^a\)改为操作后的结果,将这个思维过程反过来就可以得出这个结论。
然后就是爆搜吗?不不不——可行性剪枝:即使是每次选最大值,也不过是让当前操作数成倍增长而已,如果将剩下的次数全拿来成倍增长还不能到最终结果的话,就肯定剪掉。
完整代码
折半DFS
这种题很少,一般是将DFS需要枚举的数量减半,分批处理,最后合并答案。所以需要两个DFS
例题分析
送礼物
考虑折半,先搜索前一半的数,将其所能组成的所有不超过\(w\)的和全部存入\(num\),然后排序去重,接着再搜索后面的数,对于每一个和upper_bound
一下就行。此题卡常,为了优化空间和搜索顺序,推荐先自大到小排序再进行计算。
#define ll long long
int tot;
ll num[50505050],ans;
int st,n,m,a[1050];
void dfs1(int now,ll sum){
if(now>st){
num[++tot]=sum;
return ;
}
ll e=sum+a[now];
if(e<=m)dfs1(now+1,e);
dfs1(now+1,sum);
}
void dfs2(int now,int sum){
if(now>n){
ans=max(ans,sum+num[upper_bound(num+1,num+tot+1,m-sum)-num-1]);
return ;
}
ll e=sum+a[now];
if(e<=m)dfs2(now+1,e);
dfs2(now+1,sum);
}
void read(int &x){
x=0;char ch=getchar();
while(ch>'9'||ch<'0')ch=getchar();
while(ch>='0'&&ch<='9')
x=(x<<3)+(x<<1)+(ch^48),ch=getchar();
}
int main(){
read(m);read(n);
for(int i=1;i<=n;i++)read(a[i]);
sort(a+1,a+n+1);reverse(a+1,a+n+1);
st=n/2;
dfs1(1,0);
sort(num+1,num+tot+1);
tot=unique(num+1,num+tot+1)-num-1;
dfs2(st+1,0);
cout<<ans<<endl;
}
一般的折半搜索的基本思想就是:算前面一半,再算后面一半,然后在合并两半。这样前面两半的复杂度就少了一个根号,合并复杂度一般根据贪心不会太高。
双向BFS
适用于:有明确的起始状态和最终状态,空间允许进行HASH的题目
其板子大概是:
int bfs(){
//默认s为起点,t为终点,q为队列,vis,dis为标记/距离函数
//get(x)为得到这个节点的HASH值,node为类,mk为状态和方向合并的函数,
//.tag为方向,.val为HASH值
int S=get(s),T=get(t);
if(S==T)return 0;
queue<node>q;
q.push(mk(s,0));q.push(mk(t,1));
dis[0][S]=dis[1][T]=0;
vis[0][S]=vis[1][T]=0;
while(!q.empty()){
node u=q.front();q.pop();
for(/*枚举状态转移,设v为子状态*/){
node v;v=/*一系列计算*/;v.tag=u.tag;
if(check(v)){//合法的话
if(vis[v.tag^1][v.val]){//找到答案
return dis[v.tag^1][v.val]+dis[u.tag][u.val]+1;
}
if(vis[v.tag][v.val])continue;
vis[v.tag][v.val]=1;
int d=0;
dis[v.tag][v.val]=dis[u.tag][u.val]+1;
if(d<=step)q.push(v);//适用于限制步数的题
//这个操作可以使得有解情况下最多搜step*2+1步,最少搜2*step步
}
}
}
return -1;
}
解释:我们运用一个队列存储,但这个队列具有三段性,也即最前面一段属于tag
,步数为s
,中间一段属于tag^1
,步数为s+1
,后面一段属于tag
,步数为s+1
(tag=0/1分别表示起点终点)。
一些性质
我们设\(u.tag=0\)为从起点出发的状态,\(u.tag=1\)表示从终点出发的状态,\(u.val\)表示HASH值,设\(f\)是估价函数返回值。
将\(s\)先于\(t\)入队。
这个方法基于双向BFS的三段性。根据双向BFS的搜索树,容易得到若两次都搜索到了v.val
这个状态,则\(dis[0][v.val]-dis[1][v.val]\le 1\)
进行分类讨论,设\(v.tag\)是最后结束搜索的状态(也即\(v.tag\oplus 1\)之前就算过):
- \(v.tag=0\),容易得到\(dis[0][v.val]=dis[1][v.val]+1\)
- \(v.tag=1\),容易得到\(dis[0][v.val]=dis[1][v.val]\)
常见HASH技巧
cantor展开
适用于全排列HASH,它可以将\(1\sim n\)的全排列与\(0\sim n!-1\)一一建立映射。
其方法是,设\(a_1\sim a_n\)是一个排列,\(f(a_i)=\sum _{k=i}^n[a_k>a_i]\),则这个排列映射为的编号为:
\[\sum_{i=1}^nf(a_i)(n-i)! \]这是这个排列在全排中的字典序排名-1。
关于证明,可以思考数位DP,\(f(a_i)(n-i)!\)的组合意义就是第\(i\sim n\)位,将比\(a_i\)大的数放在第\(i\)位,后面的全排列,可以确定这些都比这个排列大。
代码:
#include<iostream>
#include<cstdio>
#include<algorithm>
using namespace std;
#define int long long
int a[17];
int cantor(int a[],int len){
int ans=0;
for(int i=1;i<=len;i++){
int c=1,m=1,cnt=0;
for(int j=i+1;j<=len;j++){
if(a[i]>a[j]){
++cnt;
}
m*=c;c++;
}
ans+=cnt*m;
}
return ans+1;
}
signed main(){
int n;cin>>n;
for(int i=1;i<=n;i++)cin>>a[i];
cout<<cantor(a,n);
}
逆康托展开
给定一个排名\(k\)和\(n\),求出\(n\)的全排中排名为\(k\)的排列。
其方法是:将\((n-1)!\)除以\(k\),商为该位数在剩下数中的排名,余数继续进行此类操作。
int jc[10]={1,1,2,6,24,120,720,5040,40320,362880};
void excantor(int x,int n){
vector<int>a,b;
for(int i=1;i<=n;i++)a.push_back(i);
for(int i=n;i;--i){
int p=x%jc[i-1],q=x/jc[i-1];
x=p;
b.push_back(a[q]);
a.erase(a.begin()+q);
}
for(int i=0;i<n;i++)cout<<b[i];
}
int main(){
int n,m;cin>>n>>m;
excantor(n-1,m);
}
STL_map
非常NB,空间仍然只是状态数量,但需要\((\log n)\)的额外时间复杂度(其实问题不大)。
具体的,常用于状态表示的数超过了\(1e7\),亦或者使用字符串存储状态,MAP的用法就不多说了。
进制HASH
也很简单,常见的是二进制,但实际上不止:
其实可以这样想,以走上下左右地图问题为例,每一位可以表示上一次进行的操作是第几个,用四进制状压即可。
对于状态压缩的常见操作:
- 取出第\(k\)位:先除再膜
- 压入第\(k\)位:膜+除+加+除
这个学过字符串HASH的大家应该都会。
例题分析
一般来说,设计双向BFS算法有以下几个步骤:
- 发现双向BFS(有明确初始态和终末态)。
- 设计HASH。
- 设计状态转移(一般是很套路的东西)。
- 开始快乐地敲板子
然后就发现挂了
其中比较复杂的是2,3,两步,在调试的时候注意检查HASH是否正确。
骑士精神
首先,因为本题有明确的初始态和终末态,可以考虑双向BFS。
按照步骤,我们先来设计状态和HASH
棋盘5X5,每个点有三个状态,可以考虑三进制HASH,\(3^{25}\)大约在\(2^{45}\)次方左右,记得开long long
。
当然,此题不卡常,可以考虑使用字符串和map
进行存储(主要是方便,用三进制也要map
)
状态转移就是普通的走地图。剩下就是套板子啦。
#include<bits/stdc++.h>
using namespace std;
int n,m,a[6][6],sx,sy,ty=3,tx=3;
struct node{
int a[6][6],tag,l,r;
string val;
};
map<string,int>vis[2],dis[2];
int lst[6][6]={{0,0,0,0,0,0},{0,1,1,1,1,1},{0,2,1,1,1,1},{0,2,2,0,1,1},{0,2,2,2,2,1},{0,2,2,2,2,2},},dx[8]={1,-1,-1,1,2,-2,-2,2},dy[8]={2,-2,2,-2,1,-1,1,-1};
void init(){
for(int i=1;i<=5;i++){
for(int j=1;j<=5;j++){
char x;cin>>x;
if(x=='*')a[i][j]=0,sx=i,sy=j;
if(x=='1')a[i][j]=1;
if(x=='0')a[i][j]=2;
}
}
}
string get(int a[6][6]){
string ans="";
for(int i=1;i<=5;i++)for(int j=1;j<=5;j++)ans+=a[i][j]+'0';
return ans;
}
bool check(int sx,int sy){
if(min(sx,sy)<1||max(sx,sy)>5)return false;
return true;
}
int bfs(){
node s,t;
memcpy(s.a,a,sizeof a);memcpy(t.a,lst,sizeof lst);
s.l=sx,s.r=sy,t.l=tx,t.r=ty;
s.tag=0,t.tag=1,s.val=get(s.a),t.val=get(t.a);
vis[0][s.val]=1,vis[1][t.val]=1;
dis[0][s.val]=dis[1][t.val]=0;
queue<node>q;
q.push(s),q.push(t);
while(!q.empty()){
node u=q.front();q.pop();
if(dis[u.tag][u.val]>8)return -1;
for(int i=0;i<8;i++){
node v;v.l=u.l+dx[i],v.r=u.r+dy[i];
if(check(v.l,v.r)){
swap(u.a[u.l][u.r],u.a[v.l][v.r]);
memcpy(v.a,u.a,sizeof v.a);
swap(u.a[u.l][u.r],u.a[v.l][v.r]);
v.val=get(v.a);
if(vis[u.tag^1][v.val]){
return dis[u.tag][u.val]+dis[u.tag^1][v.val]+1;
}
else {
if(vis[u.tag][v.val])continue;
vis[u.tag][v.val]=1;
dis[u.tag][v.val]=dis[u.tag][u.val]+1;
v.tag=u.tag;
q.push(v);
}
}
}
}
return -1;
}
int main(){
int n;cin>>n;
while(n--){
vis[1].clear();vis[0].clear();
dis[1].clear();dis[0].clear();
init();
int ans=bfs();if(ans>15)ans=-1;
cout<<ans<<endl;
}
}
八数码问题
无比经典,不过这就是双向BFS的板题……。
由于是全排列(把空格看作9),用康托展开HASH即可。
一个有趣的结论是奇数码问题可以使用逆序对来判定。
引理:奇数码问题两个状态可达的充要条件是将空格删去,把8个数排成一列后,两个序列的逆序对数的奇偶性相同
证明:
必要性:显然,左右移动空格逆序对不会变化,上下移动只会有偶数次变化,逆序对奇偶性不变。
充分性:比较困难,这里提供一种简易的证明(错了请指正)
首先\(2*2\)的数码问题是每一个状态都可达的,\(3 * 3\)的数码问题通过计算机可以验证奇/偶集内状态互相可达。
那么:对于\(x*x(x=2k+1)\)的数码问题,可以通过数学归纳法进行证明。
\(3*3\)通过计算机显然成立,我们假设\(x*x\)成立,现在证明\((x+2)*(x+2)\)也成立。
可以这样想,我们现将空格移动到左上角,然后直接用一个\(x*x\)的假想的棋盘将其框住,对数字进行离散化,此时互相可达。然后剩下的棋盘可以被拆分为若干个2*2的,只要将空格移到这些地方,也可以将这些2 * 2的棋盘的每一种可能都玩完而不会影响逆序对数。仔细思考,容易发现离散化是不会影响结果的。
所以我们可以加上逆序对数判无解。
#include<bits/stdc++.h>
using namespace std;
int cantor(int k[3][3],int len=9){
int a[15];
for(int i=0;i<3;i++)
for(int j=0;j<3;j++)a[i*3+j+1]=k[i][j];
int ans=0;
for(int i=1;i<=len;i++){
int c=1,m=1,cnt=0;
for(int j=i+1;j<=len;j++){
if(a[i]>a[j]){
++cnt;
}m*=c;c++;
}
ans+=cnt*m;
}
return ans;
}
int jc[10]={1,1,2,6,24,120,720,5040,40320,362880},sx,sy,tx,ty,q1[9],q2[9];
int dx[4]={1,-1,0,0},dy[4]={0,0,1,-1},vis[500050][3],dis[1000050][3],S[3][3],T[3][3],s,t;
bool check(int sx,int sy){
if(min(sx,sy)<0||max(sx,sy)>2)return false;
return true;
}
struct node{
int a[3][3],val,tag,l,r;
};
int bfs(){
queue<node>q;
node S1,S2;
for(int i=0;i<3;i++){
for(int j=0;j<3;j++){
S1.a[i][j]=S[i][j];
}
}
for(int i=0;i<3;i++){
for(int j=0;j<3;j++){
S2.a[i][j]=T[i][j];
}
}
S1.l=sx,S1.r=sy,S1.tag=1,S1.val=s;
S2.l=tx,S2.r=ty,S2.tag=0,S2.val=t;
q.push(S1);q.push(S2);
dis[s][1]=dis[t][0]=0;vis[s][1]=vis[t][0]=1;
while(!q.empty()){
node u=q.front();q.pop();
for(int i=0;i<4;i++){
node v;
int vx=u.l+dx[i],vy=u.r+dy[i];
if(check(vx,vy)){
swap(u.a[vx][vy],u.a[u.l][u.r]);
for(int i=0;i<3;i++){
for(int j=0;j<3;j++){
v.a[i][j]=u.a[i][j];
}
}
v.val=cantor(v.a),v.l=vx,v.r=vy;
swap(u.a[vx][vy],u.a[u.l][u.r]);
if(vis[v.val][u.tag^1]){
return dis[v.val][u.tag^1]+1+dis[u.val][u.tag];
}
else {
if(vis[v.val][u.tag])continue;
vis[v.val][u.tag]=1;
dis[v.val][u.tag]=dis[u.val][u.tag]+1;
v.tag=u.tag;
q.push(v);
}
}
}
}
return -1;
}
int main(){
for(int i=0;i<3;i++){
for(int j=0;j<3;j++){
scanf("%d",&S[i][j]);
q1[i*3+j]=S[i][j];
}
}
int cnt1=0,cnt2=0;
for(int i=0;i<9;i++){
for(int j=i+1;j<9;j++){
if(q1[i]==0||q1[j]==0)continue;
if(q1[i]>q1[j])cnt1++;
}
}
for(int i=0;i<3;i++){
for(int j=0;j<3;j++){
scanf("%d",&T[i][j]);
q2[i*3+j]=T[i][j];
}
}
for(int i=0;i<9;i++){
for(int j=i+1;j<9;j++){
if(q2[i]==0||q2[j]==0)continue;
if(q2[i]>q2[j])cnt2++;
}
}
if((cnt1+cnt2)&1){
cout<<"-1\n";
return 0;
}
for(int i=0;i<3;i++)for(int j=0;j<3;j++)if(S[i][j]==0)S[i][j]=9,sx=i,sy=j;
for(int i=0;i<3;i++)for(int j=0;j<3;j++)if(T[i][j]==0)T[i][j]=9,tx=i,ty=j;
s=cantor(S),t=cantor(T);
// cout<<"X\n";
cout<<bfs();
}
聪明的打字员
容易发现,操作具有可逆性,且有明确的初始态和终末态,可以考虑进行双向BFS。
HASH可以采用字符串(然后你就被卡了),所以我们采用十进制状压。
然后就是模拟两个操作就行。
int up_down(int num,int pos,int delta){
int t=num/fz[pos]%10+delta;
if(t>=0&&t<=9)return num/fz[pos-1]*fz[pos-1]+t*fz[pos]+num%fz[pos];
return num;
}
int exchange(int num,int pos1,int pos2){
int lst=num/fz[pos1]%10;
int now=num/fz[pos2]%10;
if(lst==now)return num;
num=num/fz[pos1-1]*fz[pos1-1]+now*fz[pos1]+num%fz[pos1];
num=num/fz[pos2-1]*fz[pos2-1]+lst*fz[pos2]+num%fz[pos2];
return num;
}
剩下的就是愉快的套板子啦。哦对了,要写个优化——去重(可以考虑双向A*,估价函数就写两状态中数字之和做差就行,也可以进行排序,然后对相同排名的做差,最后进行求和也行),不然会T(当然可以特判:123456,999999;000000,999999
).
完整代码
字串变换
我感觉这题不用说,就是板子题,贴个代码算了。
code
十一迷宫(puzzle)
- 状态设计:一个地图表示状态,不可达的点设为12(方便HASH)
- 状态哈希:万能的STL
map<string,int>
- 状态转移:找到两个空格的位置,分别上下左右转移
- 状态剪枝:空格不与空格交换,步数大于10的不入队
A-Star
其基于优先队列BFS,其主要思想是\(f(x)=g(x)+h(x)\)
其中\(f(x)\)就是放在优先队列里进行比较的值,\(g(x)\)是当前已经花费的代价,\(h(x)\)是预估未来将要花费的最小代价。
设\(h'(x)\)是状态\(x\)实际花费的代价,则需要满足:
\(h(x)\le h'(x)\),且尽量使得\(|h(x)-h'(x)|\)小。
事实上,优先队列BFS就是\(h(x)\)始终为0的情况。
这时候便可以保证用较快速度得出最优解,感性证明一下得出最优解的正确性:
在最后一步的时候,\(h(x)\)显然只能为0,此时比较的就是\(g(x)\),故不会出错。
\(h(x)\)设计技巧
例题分析
第k短路
骑士精神
排书
引蛇出洞
ID-A-Star
其本质上就是迭代加深+估价函数的DFS。是主要是用来解决A*空间复杂度过大或者不方便HASH,其效率与BFS相当(主要是适当剪枝和常数较小)
例题分析
POSLOZI
涂满它!
破坏正方形
无题
铁盘整理
双向A*
最后我们来探讨一种双向A* 算法(感谢hjl巨佬的启发)。
以下的正确性有待考究,我也未曾在实践中用过,也欢迎HACK。
简而言之,加上估价函数的双向优先队列BFS算法。实测跑得飞起。
算法成立的原因是 A *算法并非一定是到终点才可以结束搜索,只是给搜索路径指引一个正确方向而已。
不过这个算法破坏了原本BFS队列里的三段性,这导致搜索树并不均衡,但只要有优秀的估价函数,开枝散叶也比较少。
代码实现就是多加了个估价函数而已。
标签:return,val,int,高级,笔记,tag,搜索,ans,dis From: https://www.cnblogs.com/oierpyt/p/17061111.html