算法1:遍历与枚举

730 阅读6分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

什么是枚举?

枚举(enumerate),顾名思义,找出问题的可能解,然后一个一个地尝试。

什么时候用枚举?

简单而言,任何时候。借用老师的话:

  • It should be your first idea!(拿道题,没思路,就枚举)
  • It could need optimization!(过不了,找问题,再优化)
  • It would be your last solution !(回头看,枚举是最“差”的算法,但是也是解决问题最基本的方法)

机器学习中的枚举

特征选择(feature extraction)

有这么一句话在业界广泛流传:数据和特征决定了机器学习的上限,而模型和算法只是逼近这个上限而已。

深度学习能在一定程度上解决自动特征组合以及交互的问题,但是现阶段的实际应用中仍然需要做一些特征设计以及显示的特征组合。在特征选择的过程中,枚举算法仍然是重要的:即尝试→评估→优化

参数调整(parameter selection)

本质上,参数的调整都是枚举。目前常见的枚举方法是:网格搜索法。常见的采样标尺是:指数采样

信息检索(information retrieval)

对于给定的Query,遍历系统中所有的document,形成检索结果。

典型例题

立方质数(暴力枚举)

题目 algo.bjtu.edu.cn/contest/115… 如果一个质数能被表示为三个不同的质数的和的形式,那么我们称它为立方质数。现在给你一个数n,判断它是不是立方质数。

输入数据:正整数n,n<=1000

输出数据:Yes或者No

样例输入

19

样例输出

Yes

思路 新建list,标记从1到n中,所有的质数。复杂度:O(n2)\mathcal{O(n^2)} 新建list时或许用memset函数,把数组置为0;也可以逐个遍历:

# include <cstring> // 必须引入
memset(void *str, int c, size_t n) // 不要用0之外的数字作为c

具体代码如下:

int prime[1001];
memset(prime,0,1001)// 全部置为0
for (int i = 0;i<1001;i++) {
    prime[i]=1;// 全部置为1
}

遍历2到i,是否能整除;如果发现能的,就不是质数

    //int n=1000;
    int n;
    scanf("%d",&n);
    for (int i = 1; i < 1001; ++i) {
        for (int j = 2; j < i; ++j) {
            if (i % j == 0){
                prime[i] = 0; // not prime
                break;}
        }
    }

把找到的one-hot,整理为numeric:

    list<int> a;
    for (int i=1;i<=n;i++){
        if (prime[i]){
            a.push_back(i);
        }
    }

    a.erase(a.begin()); // 1不是质数,用erase(loc)去除

三层循环筛选立方质数

    for(auto num1: a){
        for(auto num2:a){
            for (auto num3:a) {
                //printf("%d %d %d %d\n",n,num1,num2,num3);
                if ((num1+num2+num3 == n) && (num1!=num2) && (num2!= num3) && (num1 !=num3) && (prime[n]==1)){
                        printf("Yes");
                        return 0;
                }
            }
        }
    }
    printf("No");
    return 0;
}

暴力枚举法总结

纯遍历的题目不难,重要的是细心、读懂题干。不要漏掉任何一个细节!!!

分木棍(二分查找)

题目 我们有n根的木棍。现在从这些木棍中切割出来m条长度相同的木棍,问这m根木棍最长有多长?

输入数据 第一行输入两个数字,n(1<=n<=1000)为木棍数目,m(1<=m<=1000)为需要切割出的相同长度的木棍数目 随后n个正整数,表示原始木棍的长度(<=10000)

输出数据 每组输出一行结果,表示切割后绳子的最长长度(保留两位小数)

样例输入

4 5 5 6 7 8

样例输出

4.00

思路 数值的范围问题,可以考虑用二分查找做。 初始区间[min,max][min,max] min和max的取值要从题意中找,一个很显然的想法是直接取平均值,但是这并不是最值。min是0.01(再往下,m无限大,无意义;max直接取最极端的情况:lmaxl_{max},这个时候m=1)

二分判断inlilcut?m\sum_{i}^n{\frac{l_i}{l_{cut}}} \geq_? m如果大于,则更新区间为[mid,max][mid,max],反之亦然

终止条件maxmin0.01|max-min|\leq 0.01注意终止条件要写在while内部;而循环条件是max>minmax>min!!!

特别注意:区间查找的变量类型必须是float/double,上次上课写错了简直社死…

//
// Created by ZixinQin on 2021/9/17.
//
# include <iostream>
# include <cstdio>
# include <algorithm>
# include <cstring>
# include <list>
using namespace std;
int m,n,l[1010];
int length_test(double max,double min){ //千万注意是浮点型
    double mid = (max+min)/2;
    int total = 0;
    for (int i = 0; i < n; ++i) {
        int num = l[i]/mid;
        total += num;
    }
    if (total >= m) return 1;
    else return 0;
}

int main() {
    cin>>n>>m;
    double max = 0;
    double min = 0.01;
    for (int i = 0; i < n; i++) {
        cin>>l[i];
        if (l[i]> max) max = l[i];
        //cout<<l[i]<<endl;
    }
    //cout<<max<<" "<<min<<endl;
    while(max > min){
        if (max - min < 0.005)
            break;
        if(length_test(min,max)){
            min = min + (max-min)/2;
        }
        else{
            max = max - (max-min)/2;
        }
    }
    double mid = (max + min) /2;
    printf("%.2f",mid);
}

二分查找总结

(摘抄自老师课件) 二分查找(bisection method)是求最值的算法 在这里插入图片描述 一般的,公式如下: 在这里插入图片描述

李老师的Lucky Number (枚举+优化)

题目 李老师的lucky number 是3,5和7,他爱屋及乌,还把所有质因数只有3,5,7的数字认定为lucky number,比如9, 15, 21, 25等等。请聪明的你帮忙算一算小于等于x的lucky number有多少个?

输入数据 一个正整数x,3=<x<=1000000000000

输出数据 小于等于x的lucky number的个数。

样例输入

49

样例输出

11

思路

暴力法求解

Let x3a5b7cx \triangleq 3^a5^b7^c,之后依次枚举a,b,c判断x是否是幸运数。x从3循环到n。注意这里枚举a,b,ca,b,c的时候,用到了高中学的换底公式logxy=log2ylog2xlog_xy=\frac{log_2y}{log_2x}缩小范围。 算法效率:O(n4)\mathcal{O}{(n^4)},直接TLE(20).

typedef long long LL;
int lucky_test(int n){
    for (LL a = 0; a <= log(n)/ log(3); ++a) {
        for (LL b = 0; b <= log(n)/log(5); ++b) {
            for (LL c = 0; c <= log(n)/log(7); ++c) {
                if (pow(3,a)* pow(5,b)* pow(7,c) == n && a+b+c>0){
                    // product should not be 1
                    return 1;
                }
            }
        }
    }
    return 0;
}

int main() {
    LL n;
    cin>>n;
    int count = 0;
    for (int i = 3; i <= n ; i++) {
        if (lucky_test(i))
            count +=1;
    }
    cout<<count;
}

简单优化求解

我们可以发现,在计算过程中是存在一些重复性的。例如,我们判定了x是幸运数后,就可以知道3x,5x,7x也是。那么,我们可以在判定“最后一个幸运数”的时候,把前面的幸运数都顺便找到并记录。

效率:O(n3)\mathcal{O}{(n^3)},WA(60). 2021.10.4更新:AC了,错误原因是函数的返回类型和参数类型也要LL!!!!

using namespace std;
typedef long long LL;

int lucky_test2(int n){
    LL a,b,c = 0;
    LL cnt = 0;
    for (a = 0; pow(3,a) <= n; ++a) {
        for (b = 0; pow(3,a)* pow(5,b) <= n; ++b) {
            for (c = 0; pow(3,a)* pow(5,b)* pow(7,c) <= n; ++c) {
                if (a+b+c>0){// product should not be 1
                    cnt ++;
                }
            }
        }
    }
    return cnt;
}

int main() {
    LL n;
    cin>>n;
    LL count = lucky_test2(n);
    cout<<count;
}

总结

  1. 函数的参数类型一定不要忽略!
  2. for循环的第二个条件、第三个条件是比较灵活的。

李老师的暑假旅行(简单背包:二叉树,DFS)

题目 李老师正准备暑假旅行,他有一个容量为L的行李箱和n个物品(n不超过20),每个物品都有自己的体积,物品可以放入行李箱,但行李箱中物品的总体积不能超过行李箱容量,李老师现在想知道他有多少种携带物品的方案(一个物品都不带也算一种方案)

输入数据 第一行为两个正整数n和L,分别代表物品总数和行李箱容量,n<=20,L<=1e9 接下来一行为n个正整数vi,代表第i个物品的体积,vi<=1e8

输出数据 方案数

样例输入

3 10 2 4 5

样例输出

7

枚举二叉树路径求解

背包问题有很多做法:常见的是DP / 递推;而所有的DP问题,本质上都是有限集中的最值问题。

但是在这一章节中,我们主要讨论的是枚举算法,而枚举的对象,是二叉树的路径(path)。

这里主要是要看懂深度优先搜索(Depth First Search) 这一块。

void dfs(int v[],int id, int vol,int n, int L){

是函数的声明,参数包括:

  • 规划数组 (programming tablet)
  • 状态表示 f(i,j)f (i , j )
  • 其它需要的额外信息(也可以不作为参数,直接写在int main()外面,但是这样不能debug)
    if (id == n){ // 到达了状态尽头
        if (vol <= L){
            plan++;
        } // 判断是否符合条件,是的话就增加全局变量的值
        return; // 回溯,否则算法无法终止
    }
    dfs(v,id+1,vol,n,L); // 没有到达搜索的尽头,因此继续分支(不选第id个)
    dfs(v,id+1,vol+v[id],n,L); //选了第id个
}

代码实现

//
// Created by ZixinQin on 2021/9/17.
//
using namespace std;

int plan = 0;

void dfs(int v[],int id, int vol,int n, int L){
    // terminal state
    if (id == n){
        // check if satisfy requirement
        if (vol <= L){
            plan++;
        }
        return;
    }
    //cout<<id<<" "<<vol<<endl;
    dfs(v,id+1,vol,n,L);
    dfs(v,id+1,vol+v[id],n,L);
}

int main() {
    int n,L;
    cin>>n>>L;
    int v[21];
    for (int i=0;i<n;i++)
        cin>>v[i];
    dfs(v,0,0,n,L);
    cout<<plan<<endl;
    return 0;
}

小结 写DFS:(1)状态表示(不重不漏) (2)状态转移(分到达尽头;没有达到尽头,如果没有达到就分支)

移除石头(NOIP原题)

题目 有一条河,河中间有一些石头,已知石头的数量和相邻两块石头之间的距离。现在可以移除一些石头,问最多移除m块石头后(首尾两块石头不可以移除),相邻两块石头之间的距离的最小值最大是多少。

输入数据 第一行输入两个数字,n(2<=n<=1000)为石头的个数,m(0<=m<=n-2)为可移除的石头数目;随后n-1个数字,表示顺序和相邻两块石头的距离d(d<=1000)

输出数据 输出最小距离的最大值

样例输入

4 1 1 2 3

样例输出

3

思路

二叉树枚举求解

和上一题相似:

  • 枚举每种取or不取的可能,形成一个二叉树。
  • 每个路径对应一种解。 依次计算每个解的最小值,最后取最大值。

代码实现:

//
// Created by ZixinQin on 2021/9/17.
//
# include <iostream>
# include <cstdio>
# include <algorithm>
# include <cstring>
# include <list>
using namespace std;
typedef long long LL;

int d_max;

int check(int d[],int flag[],int n){
    int local_min = n*1001;
    for (int i=1;i<n;i++){
        if (flag[i]!=1){
            local_min = min(local_min,d[i-1]); }
        else{
            int index = i-1;
            int acc_d = d[index];
            while (flag[i]==1){
                i++;
                acc_d += d[i-1];
            }
            local_min = min(local_min,acc_d);
        }
    }
    return local_min;
}


void dfs(int id,int removed,int d[],int flag[],int n,int m){
    if (id == n){
        if(removed!=m)
            return;
        int dist = check(d,flag,n);
        if (d_max < dist)
            d_max = dist;
        return;
    }
    if(removed<m && id!=0 && id!=n-1){
        flag[id] = 1;
        dfs(id+1,removed+1,d,flag,n,m);
    }
    flag[id] = 0;
    dfs(id+1,removed,d,flag,n,m);
}

int main() {
    int n,m;
    int d[1010];
    int flag[1010];
    cin>>n>>m;
    for (int i = 0; i < n-1; ++i) {
        cin>>d[i];
    }
    dfs(0,0,d,flag,n,m);
    cout<<d_max;
    return 0;
}

复杂度分析:O(2n)\mathcal{O}(2^n),TLE(60)

二分法优化求解

题目的描述是求最小值的最大值。 也就是说,所求问题满足:若c(x)Sc(x)\in S,则c(ppx)Sc(p|p\leq x )\in S的特性。因此,可以构造二分法求解。 在这里,我们给出了两种实现。实现2参考自老师PPT,实现1参考自:blog.csdn.net/qq_45249273…

这道题非常。需要根据数据调整二分法的误差容忍范围(eps),如果选1会WA,选0.01会TLE,选0.1才能全部通过。这非常像机器学习中的调参…

//
// Created by ZixinQin on 2021/9/17.
//
# include <cstdio>
using namespace std;
int n,m;
int a[1002];
int dis[1002];

bool validate(int middle)
{
    int removed=0;
    int st=1;//最初的石头
    for(int i=2;i<=n;i++)//i表示结尾的石头
    {
        if(dis[i]-dis[st]<middle)//如果相邻距离小于middle,则移动石头
            removed++;
        else //否则就更新初始石头
            st=i;
    }
    if(removed>m)
        return 0;
    else
        return 1;
}


int validate2(int mid){
    int k = m; // 可以移动的数目
    int st=1;
    for(int end=2;end<=n;){
        int distance = dis[end]-dis[st];
        // 类似于双指针算法
        while (distance < mid){ // 计算次数可以remove多少个石头
            k--;
            end++;
            if (k<0) return 0; //移动的多了,不合理
            if (end > n){
                if (st==1) return 0;
                return 1;
            }
            distance = dis[end] -  dis[st]; // accumulated disttance
        }
        st = end;
        end ++;
    }
    return 1;
}

int main() {
    dis[1] = 0;
    scanf("%d %d",&n,&m);
    for (int i = 2; i <= n; ++i) {
        scanf("%d",&a[i]);
        dis[i] = dis[i-1]+a[i];
    }
    double lb = 0, ub = 1000*1000+5;
    while(ub-lb>0.2){
        double mid = (lb + ub)/2; // distance is of integer type
        if (validate2(mid))
            lb = mid;
        else
            ub = mid-1;
    }
    printf("%d",(int)lb);
    return 0;
}

数组元素和(组合优化)

题目描述 给你一个长度为n的数组和一个正整数k,问从数组中任选两个数使其和是k的倍数,有多少种选法 对于数组a1=1 , a2=2 , a3=2而言: (a1,a2)和(a2,a1)被认为是同一种选法; (a1,a2)和(a1,a3)被认为是不同的选法。

输入数据 第一行有两个正整数n,k。n<=1000000,k<=1000000 第二行有n个正整数,每个数的大小不超过1e9

输出数据 选出一对数使其和是k的倍数的选法个数

样例输入

5 6 1 2 3 4 5

样例输出

2

样例说明 样例解释:a1+a5=6,a2+a4=6,都是6的倍数 所以符合条件的选法有(1,5),(2,4)

暴力搜索

//
// Created by ZixinQin on 2021/9/17.
//
# include <cstdio>
# include <iostream>
using namespace std;
int a[1000010];

int main() {
    int n,k;
    cin>>n>>k;

    for (int i = 0; i < n; ++i) {
        cin>>a[i];
    }
    int acc = 0;
    for (int i = 0; i < n; ++i) {
        for (int j = i+1; j < n; ++j) {
            if ((a[i]+a[j]) % k ==0 )
                acc++;
        }
    }
    cout<<acc<<endl;
    return 0;
}

组合优化

//
// Created by ZixinQin on 2021/9/17.
//
# include <cstdio>
using namespace std;
typedef long long LL;
int a[1000010];

int main() {
    int n,k;
    scanf("%d %d",&n,&k);

    for (int i = 0; i < n; i++) {
        int tmp;
        scanf("%d",&tmp);
        a[tmp%k] += 1;
    }
    LL acc = 0;
    for (int i = 0; i < k; ++i) {
        int j = (k-i) % k;
        if(i>j)
            break;
        else if (j==i)
            acc += 1LL*a[i]*(a[i]-1)/2;
        else
            acc += 1LL*a[i]*a[j];
    }
    printf("%lld",acc);
    return 0;
}