遇到的一些好用算法总结


  写在正文前:由于近期在LeetCode刷题,遇到了很多很厉害的算法,出于分享同时也是对自己掌握内容的总结,考虑推出这篇博文,欢迎各位指教!
  笔者根据知乎提问“世界上有哪些代码量,但很牛逼很经典的算法或项目案例?”以及在LeetCode中遇到的比较经典的一些算法进行了总结,后续看情况不定期更新

附上链接:https://www.zhihu.com/answer/974983270


1. KMP算法(解决字符串模式匹配问题)

  字符串的模式匹配,是求一个字符串T(模式串)在另一个字符串S(主串)中的位置

  KMP 算法是 D.E.Knuth、J,H,Morris 和 V.R.Pratt 三位神人共同提出的,称之为 Knuth-Morria-Pratt 算法,简称 KMP 算法。该算法相对于 Brute-Force(暴力)算法有比较大的改进,主要是消除了主串指针的回溯,从而使算法效率有了某种程度的提高。

  首先,谈一谈解决这个问题有哪些思路:
  1. 暴力搜索:
  从s的第一个位置开始取跟T等长的子串,将子串依次跟T中的每个元素比较,一旦不同,则s移动至下一个位置重新取子串进行比较,直至成功匹配,或s的所有子串都已经比较完
  时间复杂度:O(NM) 空间复杂度:O(1)

int Index(string s, string t) {
    int i =0, j = 0;
    while(i < s.length() && j < s.length()) {
        if(s[i] == s[j]) {
            i++;
            j++;
        }
        else {
            i = i - j + 1;
            j = 0;
        }
    }
    if(j == s.length())
        return i - j;
    return -1;
}

  2. Hash:
  设|S| = n , |T| = m。如果不考虑冲突,那么我们可以将 S 的所有长度为 m 的子串hash值都求出来,复杂度为O(N)。将这 n-m+1 个子串与T的hash值在O(1)的时间内一一比对,即可通过hash值是否相同来判断是否匹配成功。
  但实际上如果n和m很大(1e6),那么散列值冲突是不可避免的,此时需要二次判断或者通过其他方法(构造更好的散列函数)来在保证速度的情况下提升正确性。
  时间复杂度:O(N) 空间复杂度:O(N)
  哈希函数:

H[i] = H[i - 1]*p + val[i]
typedef unsigned long long ull;
const ull B = 100000007;    //哈希的基数

int Index(string s, string t) {
    int n = s.length(), m = t.length();  
    unordered_map<int, int> hashmap;
    ull sh = 0, th = 0, t = 1;
    for(int i = 0; i < m; i++)
    {
        sh = sh * B + s[i];      //s和t长度为m的前缀对应哈希值
        th = th * B + t[i];
        t *= B;
    }    
    for(int i =0; i < n - m; i++) {
        if(sh == th)
            return i;
        sh = sh * B + s[i + m] - s[i] * t;
    }
    return -1;
}

  3. KMP:
  我们可以发现暴力搜索的时候,中间有很多位置重复进行了不必要的判断。

  用next数组记录模式串(T)中每个位置上发生匹配失败时,下一次和主串对应位置比对的字符下标

  next数组:
  假设我们 S[i] 和 T[j] 发生了失配,如果我们知道 “T 中以 j 为末尾的真子串” 和 T[0, j)的最长公共前后缀子串T[0,t),那么下一次T跟S[i]比较的字符下标就为t,也就是说在next数组中j对应转移下标为t,即next[j] = t。
  讲人话就是:对于模式串T,T[0,t)和T[j-t, j)是匹配的,即T[0,t) = T[j-t, j),而我们又知道S[i-j,i) = T[0,j),那么必然有S[i-t,i) = T[j-t,j) = T[0,t),所以我们下一次匹配从t开始即可
  时间复杂度:O(M + N) 空间复杂度:O(M)

  计算next数组和KMP算法代码如下:

void get_next(string t, int next[]) {
    int i = 0;
    next[0] = -1;    //第一个字符不匹配 next值设为-1
    int j = -1;
    while(i < t.length()) {
        if(j == -1 || t[i] == t[j])   
    //j == -1判断条件解决了当t[1] != t[0]时 j = next[0] = -1 数组越界
    //同时 j == -1时 next[0] = -1符合情况
        {                
            ++i;
            ++j;
            next[i] = j;    //i和j匹配时 指向j+1
        }
        else            //i和j不匹配时 找更短的前后缀
            j = next[j];
    }
}

int KMP(string s, string t) {
    int i = 0, j = 0;
    while(i < s.length() && j < t.length()) {
        if(j == -1 || s[i] == s[j])   
    //j == -1判断条件是因为next数组中有-1的情况 造成数组越界
    //j == -1表明第一个字符都不匹配 s应该从下一个位置开始匹配 ++j后 j = 0
        {
            ++i;
            ++j;
        }
        else
            j = next[j];
    }
    if(j == t.length())    return i - j;
    return -1;
}

2. 约瑟夫环

  问题来源是LeetCode 面试题62. 圆圈中最后剩下的数字(https://leetcode-cn.com/problems/yuan-quan-zhong-zui-hou-sheng-xia-de-shu-zi-lcof/)
  约瑟夫环是一个数学的应用问题:已知n个人(以编号1,2,…n分别表示)围坐在一张圆桌周围。从编号为k的人开始报数,数到m的那个人出列;他的下一个人又从1开始报数,数到m的那个人又出列;依此规律重复下去,直到圆桌周围的人全部出列。

  解决问题的思路有哪些:
  1. 循环链表:
  相当于模拟过程,具体代码不展示
  时间复杂度:O(mn) 空间复杂度:O(n)

  2. 递归(非递归)
  我们假设函数f(n,m)表示n个人每隔m个人淘汰一个人,最终留下来的那个人的编号。第一轮我们淘汰编号为m%n的人,此时剩下n-1个人,且下一轮第一个报号的人编号为m%n+1,也就相当于对于只有n-1个人的情况,所有人的编号往后平移m%n,我们假设f(n-1,m) = x,此时我们可以建立递推公式:

f(n,m) = (f(n-1,m) + m % n) % n = (x + m) % n......f(1,m) = 1

  时间复杂度:O(n) 空间复杂度:O(n)或O(1)

//递归
int  lastRemain(int n, int m) {
    if(n == 1)
        return 1;
    int lastRemain(n - 1, m) = x;
    return (m + x) % n;
}

//非递归
int  lastRemain(int n, int m) {
    int x = 1;
    for(int i = 2; i <= n; i++)
        x = (x + m) % i;
    return x;
}

  3. 数学方法
  假设初始编号为 1,2,…,n,现在考虑一种新的编号方式。
  第一个人不会被踢掉,那么他的编号从n开始往后加 1 ,变成 n + 1,然后第二个人编号变为 n + 2,直到第 m个人,他被踢掉了。
  然后第 m + 1个人编号继续加 1 ,变成了n + m,依次下去。
  考虑当前踢到的人编号为km,那么此时已经踢掉了k个人,所以接来下的人的新编号为n + k(m -1) + 1…(如果没有踢掉人的话,经过k轮编号应该多了km,现在每一轮少一个编号)
  所以编号为km + d的人编号变成了n + k(m - 1) + d,其中1 <= d < m。
  直到最后,活下来的人的编号为mn(假设一共有n轮,活下来的那个人一定是在第n轮被踢掉的人,所以他的编号一定为nm
  接下来的问题就转换成怎么由新编号得知他之前的原始编号
  我们假设第k轮时他的编号是N = n + k(m - 1) + d,那么他上一轮的编号是

km + d = km + N - n - k(m - 1) = k + N - n
递推公式:k + N - m = N(k) = k + N(k + 1) -m
k = (N -n - d)/(m - 1) = floor((N -n -1) / (m - 1))

  时间复杂度:O(logn) 空间复杂度:O(1)

int lastRemain(int n, int m) {
    int N = n*m;
    while(N > n) {
        int k = floor((N -n -1) / (m - 1));
        N = k + N -n;
    }
    return N;
}

3. 投票算法(多数元素)

  问题来源于LeetCode 169.多数元素(https://leetcode-cn.com/problems/majority-element/)
  给定一个大小为 n 的数组,找到其中的多数元素。多数元素是指在数组中出现次数大于 ⌊ n/2 ⌋ 的元素。你可以假设数组是非空的,并且给定的数组总是存在多数元素。

  此题解题方法较多,且不难想到:
  1. Hash表:
  我们使用哈希映射(HashMap)来存储每个元素以及出现的次数。对于哈希映射中的每个键值对,键表示一个元素,值表示该元素出现的次数。
  时间复杂度:O(n) 空间复杂度:O(n)

class Solution {
public:
    int majorityElement(vector<int>& nums) {
        unordered_map<int, int> counts;
        int majority = 0, cnt = 0;
        for (int num: nums) {
            ++counts[num];
            if (counts[num] > cnt) {
                majority = num;
                cnt = counts[num];
            }
        }
        return majority;
    }
};

  2. 排序
  如果将数组 nums 中的所有元素按照单调递增或单调递减的顺序排序,那么下标为floor(n/2)的元素(下标从 0 开始)一定是众数。
  时间复杂度:O(nlogn) 空间复杂度:O(logn)

class Solution {
public:
    int majorityElement(vector<int>& nums) {
        sort(nums.begin(), nums.end());
        return nums[nums.size() / 2];
    }
};

  3. 分治
  如果数 a 是数组 nums 的众数,如果我们将 nums 分成两部分,那么 a 必定是至少一部分的众数。
  我们可以使用反证法来证明这个结论。假设 a 既不是左半部分的众数,也不是右半部分的众数,那么 a 出现的次数少于 l / 2 + r / 2 次,其中 l 和 r 分别是左半部分和右半部分的长度。由于 l / 2 + r / 2 <= (l + r) / 2,说明 a 也不是数组 nums 的众数,因此出现了矛盾。所以这个结论是正确的。
  这样以来,我们就可以使用分治法解决这个问题:将数组分成左右两部分,分别求出左半部分的众数 a1 以及右半部分的众数 a2,随后在 a1 和 a2 中选出正确的众数。
  时间复杂度:O(nlogn) 空间复杂度:O(logn)

class Solution {
    int count_in_range(vector<int>& nums, int target, int lo, int hi) {
        int count = 0;
        for (int i = lo; i <= hi; ++i)
            if (nums[i] == target)
                ++count;
        return count;
    }
    int majority_element_rec(vector<int>& nums, int lo, int hi) {
        if (lo == hi)
            return nums[lo];
        int mid = (lo + hi) / 2;
        int left_majority = majority_element_rec(nums, lo, mid);
        int right_majority = majority_element_rec(nums, mid + 1, hi);
        if (count_in_range(nums, left_majority, lo, hi) > (hi - lo + 1) / 2)
            return left_majority;
        if (count_in_range(nums, right_majority, lo, hi) > (hi - lo + 1) / 2)
            return right_majority;
        return -1;
    }
public:
    int majorityElement(vector<int>& nums) {
        return majority_element_rec(nums, 0, nums.size() - 1);
    }
};

  4. Boyer-Moore 投票算法
  如果我们把众数记为 +1+1,把其他数记为 -1−1,将它们全部加起来,显然和大于 0,从结果本身我们可以看出众数比其他数多。

  Boyer-Moore 算法的本质和分治类似:

  1. 我们维护一个候选众数 candidate 和它出现的次数 count。初始时 candidate 可以为任意值,count 为 0;

  2. 我们遍历数组 nums 中的所有元素,对于每个元素 x,在判断 x 之前,如果 count 的值为 0,我们先将 x 的值赋予 candidate,随后我们判断 x:
     2.1 如果 x 与 candidate 相等,那么计数器 count 的值增加 1;
     2.2 如果 x 与 candidate 不等,那么计数器 count 的值减少 1。

  3. 在遍历完成后,candidate 即为整个数组的众数。

  时间复杂度:O(n) 空间复杂度:O(1)

class Solution {
public:
    int majorityElement(vector<int>& nums) {
        int candidate = -1;
        int count = 0;
        for (int num : nums) {
            if (num == candidate)
                ++count;
            else if (--count < 0) {
                candidate = num;
                count = 1;
            }
        }
        return candidate;
    }
};

4. 洗牌算法

  转载知乎大佬程序员吴师兄的回答(https://www.zhihu.com/question/358255792/answer/974431591)
  先来思考一个问题:有一个大小为 100 的数组,里面的元素是从 1 到 100 按顺序排列,怎样随机的从里面选择 1 个数?
  最简单的方法是利用系统的方法 Math.random() * 100 ,这样就可以拿到一个 0 到 99 的随机数,然后去数组找对应的位置就即可。
  接下来在思考一个问题: 有一个大小为100的数组,里面的元素是从 1 到 100 按顺序排列,怎样随机的从里面选择 50 个数?注意数字不能重复!注意数字不能重复!注意数字不能重复!
  如果根据上面的思路,你第一想法是:随机 50 次不就行了?但是,这样做有个很明显的 bug :数字是会重复的。
  修改一下?弄一个数组,把每一次随机的数都放到数组里,下一次随机就看这个数组里面有没有这数,有的话就继续随机,直到这个数组里面有 50 个数字就停止。这样是可以的!但,还是有个小问题,考虑一下极端情况:有一个大小为100的数组,里面的元素是从 1 到 100 按顺序排列,怎样随机的从里面选择 99 个数。如果按照上面的方法操作,越往后选择的数字跟前面已经挑选的数字重复的概率越高,这就会造成如果数组很大,选择的数字数目也很大的话,重复次数在量级上会很大。
  这个时候就需要换一个思路,如果先将数组里面的元素打乱,那么按顺序选择前 50 个不就可以了?
——–接下来就是响当当的洗牌算法出场了——–
  这个算法很牛逼却很好理解,通俗的解释就是:将最后一个数和前面任意 n-1 个数中的一个数进行交换,然后倒数第二个数和前面任意 n-2 个数中的一个数进行交换。。。

void shuffle(vector<int>& nums) {
    int len = nums.size();
    for(int i = len - 1; i >= 0; i--)
        swap(nums[i], nums[rand(0,i)]);
}

  tips:其实就相当于是数学中的全排列,目前库里已经有shuffle函数,可以直接调用,在蚁群算法初始化蚂蚁位置还有遗传算法赌轮盘中还是很有用处滴!


To be continue….


文章作者: xiang612
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 xiang612 !
 上一篇
C++中isalpha、isalnum、islower、isupper等的用法 C++中isalpha、isalnum、islower、isupper等的用法
isalpha、islower、isupper、isalnum、isblank、isspace这些函数都在<cctype>(即C语言中的<ctype.h>)的头文件里面 1. toupper()int toupper
2020-06-19 xiang612
下一篇 
背包问题总结 背包问题总结
以下内容是笔者根据背包九讲以及B站up主大雪菜讲解视频后总结的一些笔记,欢迎各位同道中人不吝赐教附上链接:  CSDN博友分享(https://blog.csdn.net/yandaoqiusheng/article/details/847
2020-05-20 xiang612
  目录