Skip to content

Latest commit

 

History

History
657 lines (478 loc) · 19.4 KB

【B】算法 -- 01_字符串问题.md

File metadata and controls

657 lines (478 loc) · 19.4 KB

【B】00_字符串问题

字符串问题集合

  • KMP算法
  • 给一串英文语句,让你对语句进行翻转(this is a word -> word a is this)
  • 字符串左旋
  • 一个字符串中找到最开始出现的不重复的字符
  • 给定一个只包括 ‘(’,’)'的字符串,判断字符串是否有效。注:空字符串属于有效字符串
  • 俩大数相加
  • 验证回文串 ; 找出 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。

模式匹配算法

暴力匹配

基本思想是:

  1. 从主串的第一个字符起与子串的第一个字符进行比较,若相等,则继续逐对字符进行后续的比较;

  2. 若不相等,则从主串第二个字符起与子串的第一个字符重新比较,以此类推, 直到子串中每个字符依次和主串中的一个连续的字符序列相等为止,此时称为匹配成功。

  3. 如果不能在主串中找到与子串相同的字符序列,则匹配失败。

public static int bruteForce(String s,String p) {
	int index = -1; //成功匹配的位置,匹配不到返回-1
	int sLength = s.length();
	int pLength = p.length();
	if(sLength < pLength) {
		 System.out.println("Error.The main string is greater than the sub string length.");
		return -1;
	}
	int i = 0;//当前遍历主串的下标
	int j = 0;//当前遍历字串的下标
	//暴力遍历串
	while(i < sLength && j < pLength)  {
		if(s.charAt(i) == p.charAt(j)) {
			//字符相等,指针后移
			i++;
			j++;
		}else {
			//主串回到上次匹配的字符的下一个字符,子串从0开始
			i = i - j + 1;//主串需要归还i向前走的j步,然后加1
			j = 0; //子串无须关注向前走了多少步,直接归零
		}
	}
	
	if(j == pLength) { //匹配成功
		index = i - j;
		System.out.println("Successful match,index is:" + index);
	} else {// 匹配失败
        System.out.println("Match failed.");
    }
	return index;
}

KMP算法

KMP的核心思想是主串不回溯。模式串少回溯。

真前缀和真后缀

”Harry”的真前缀包括{”H”, ”Ha”, ”Har”, ”Harr”}

”Potter”的真后缀包括{”otter”, ”tter”, ”ter”, ”er”, ”r”}

字符串既是自身的前缀也是自身的后缀,不包含自身的,则称为真前缀和真后缀。

PMT 表 与 next数组

实现KMP算法的核心在于PMT (部分匹配表,Partial Match Table)这个数据结构。对于字符串“abababca” ,它的PMT如下表示:

char a b a b a b c a
i 0 1 2 3 4 5 6 7
PMT 0 0 1 2 3 4 0 1

PMT 中记录的是模式串“abababca” 截止index位置的子串,即[0,index]区间内构成的子串,前缀集合与后缀集合的交集中最长元素的长度。 例如:i = 4处,子串为“ababa” ,前缀集合{"a","ab","aba,"abab"}, 后缀集合 {"baba","aba","ba","a"} , 该处PMT值就为“aba”的长度3.

为了方便失配时回溯, 我们不直接使用PMT数组,而是将PMT数组向后偏移一位。我们把新得到的这个数组称为next数组。在把PMT进行向右移时,第0位的值,我们将其设成了-1,i 对应的值,就是 i 处的字符之前的子串的“前后缀最大交集元素个数”,假设是m。这样,一旦在 i 位置发生失配(该位置之前都是匹配成功的),不需要从模式串头再重新匹配,而是“跳过”前面 m个元素的长度,或者说模式串向右移动 i- m 位,然后接着比较。

next数组:

char a b a b a b c a
i 0 1 2 3 4 5 6 7
PMT 0 0 1 2 3 4 0 1
next -1 0 0 1 2 3 4 0

例如在 i= 4处 字符 ‘a’ 失配,对应的next[4] = 2 表示 "abab" 前后缀最大公共子集长度为2,即{"a","ab","aba"} ∩ {“bab”,"ab","b"} = "ab" 。模式串回溯、新一轮比较时,0,1位置的“ab” 就直接略过比较,从i=2位置开始。从效果上看,就是前缀的“ab” ,移动到了后缀"ab" 的位置。

A5CACE3223014017DDF19C55F6440C51

public static void kmp(String s ,String p) {
	int[] next = buildNext(p);//姑且设定有个函数,可以提供next[]数组,告知失配时模式串最多右移位数
	int index = -1;//返回成功匹配的位置,没查找到返回-1
	int sLength = s.length();
	int pLength = p.length();
	if(sLength < pLength) {
		return;
	}
	int i = 0;
	int j = 0;
	while(i < sLength && j <pLength) {
        // j==-1 处理第0个位置的元素失配的情况
		if(j == -1 || s.charAt(i) == p.charAt(j)) {
			i++;
			j++;
		}else {
      // 主串 i 不变
			j = next[j];//表示在j位置失配时,查找一下next表中记录的,前后缀相交个数,作为下次匹配时的起始下标。
		}
	}
	if(j >= pLength) {
		 index = i -j;
		 System.out.println("KMP Successful match,index is:" + index);
	}else {
		 System.out.println("Match failed.");
	}
	long endTime = System.currentTimeMillis();
	System.out.println("KMP cost time = "+(endTime - beginTime));
}

每个字符串都有固定的next数组,它可以看作是字符串本身关于前后缀的一种特性。但是怎么通过程序得出模式串的这种特性 -- 构造出next数组呢?

对于位置i , 其前缀是固定的,后缀是相对的。

因为,前缀第一个字符固定是从0开始的,

而变量 i 对应的字符,表示的是后缀第一个字符,也就是最小真后缀。

前缀 j 即 i-1 从0开始与之匹配,理想状态下,最大真前缀与最大真后缀完全相同的,比如串 “aaaaaa” , j一直指向最大真前缀最后一个字符 , 可以一直递增,即next[i] 可以一直递增。如果失配,表示最大真前缀与最大真后缀不匹配了, 要退而求其次 , 仍通过kmp的算法思想,回溯尝试找到次大真前缀与之匹配,直到j 回溯为 0,如果仍然匹配失败,说明最小真前缀与最小真后缀不匹配,本轮查找前后缀公共子集结束。

	//构造next表
 private static int[] buildNext(String p) {
        int pLength = p.length();
        int[] next = new int[pLength]; //next数组跟串本身是等长的
        next[0] = -1; // 把第0 位置空出来,直接赋值 -1 ;从而实现PMT表右移;
        int i = 0; //  前缀
        int j = -1; // 后缀
        while (i < pLength - 1) {
            if (j == -1 || p.charAt(i) == p.charAt(j)) {//匹配结果存到next[i++]位置
                i++;
                j++;
                next[i] = j; // next[1] = 0;后缀第二个字符 i = 1 与 前缀第一个字符 j = 0 ,根据next表的定义,仍记入0
                             // next[i] : 连续匹配成功, i 位置之前,连续匹配成功字符数
            } else {
                j = next[j]; // 如果失配,按照kmp算法思想,取出j处字符之前的字串,前后缀最大交集数,再与i位置字符(最小真后缀)比较,
            }
        }
        return next;
    }

KMP算法的改进

对于特殊模式串,x处发生失配,但是子串“aaaaa” 前缀后缀都是相同,按照之前j=next[j] ,那 j 就要5,4,3,2,1这样依次回溯比对,这是没有必要的。新一轮应直接从头开始比对。

截屏2020-11-13 下午2.36.02

	//构造next表
  public static int[] buildNext(String p) {
        int pLength = p.length();
        int[] next = new int[pLength];
        next[0] = -1; // 把第0 位置空出来,直接赋值 -1 ;从而实现PMT表右移;
        int i = 0; //  后缀
        int j = -1; // 前缀
        while (i < pLength - 1) {
            if (j == -1 || p.charAt(i) == p.charAt(j)) {
                i++;
                j++;
                if (p.charAt(i) != p.charAt(j)) { //
                    next[i] = j;
                } else {
                    next[i] = next[j];
                }
            } else {
                j = next[j]; // 如果失配,取出成功上轮匹配成功时保存的数,子串回溯到 next[j]
            }
        }
        for (int index = 0; index < next.length; index++) {
            System.out.println(" " + index + " = " + next[index]);
        }
        return next;
    }

字符串翻转

双指针法

二度反转

    /**
     * 字符串反转,双指针法
     * 1. 加工源字符串,去除首尾及单词间多余空格
     * 2. 对整个串反转 -- 首尾两个指针不断交换指向的字符,同步向中间走
     * 3. 二度反转 -- 对每个单词进行反转
     *
     * @param s
     * @return
     */
    public String reverseWords(String s) {
        StringBuilder sb = trimSpace(s);
        reverse(sb, 0, sb.length() - 1);
        //查找单词位置
        reverseWord(sb);
        return sb.toString();
    }

    private StringBuilder trimSpace(String s) {
        s = s.trim();//去首尾空格
        StringBuilder trimStr = new StringBuilder();
        for (int i = 0; i < s.length(); i++) {
            char c = s.charAt(i);
            if (c == ' ' && (i > 0 && s.charAt(i - 1) == ' ')) {
                //排除单词间多余空格
                continue;
            }
            trimStr.append(c);
        }
        return trimStr;
    }

    private void reverseWord(StringBuilder sb) {
        int n = sb.length();
        int start = 0, end = 1;
        while (end < n) {
            //单词末尾
            while (end < n && sb.charAt(end) != ' ') {
                end++;
            }
            reverse(sb, start, end - 1);//end处是空格,不参与反转
            end++;
            start = end;
        }
    }

    private void reverse(StringBuilder sb, int start, int end) {
        int head = start;
        int tail = end;
        while (head < tail) {
            char tmp = sb.charAt(head);
            sb.setCharAt(head, sb.charAt(tail));
            sb.setCharAt(tail, tmp);
            head++;
            tail--;
        }
    }
双端队列法

字符串分割法

给一串英文语句,让你对语句进行翻转(this is a word -> word a is this)。

解释:

  1. 输入字符串可以在前面或者后面包含多余的空格,但是反转后的字符不能包括。

  2. 如果两个单词间有多余的空格,将反转后单词间的空格减少到只含一个。

    /**
     * 单词反转
     * 输入: "  hello world!  "
     * 输出: "world! hello"
     * 解释: 1. 输入字符串可以在前面或者后面包含多余的空格,但是反转后的字符不能包括。
     *      2. 如果两个单词间有多余的空格,将反转后单词间的空格减少到只含一个。
     */
 public String reverseWords(String s) {
        if (s == null) {
            return "";
        }
        StringBuilder res = new StringBuilder();
        String[] splitArr = s.trim().split(" ");
        int length = splitArr.length;
        for (int i = length -1 ;i >= 0; i--){
            if ("".equals(splitArr[i])){
                continue;
            }
            res.append(splitArr[i]);
            if (i > 0){
                res.append(" ");
            }
        }
        return res.toString();
  }

字符串左旋

示例 1:

输入: s = "abcdefg", k = 2
输出: "cdefgab"
 /**
     * 字符串左旋
     * 思想:
     * 以index = n 为中轴,分别读两次源字符串s,通过StringBuilder res拼接起来
     *
     * @param s 源字符串
     * @param n 旋转中轴(将下标n之前的元素旋转)
     * @return 结果字符串 res
     */
    public String reverseLeftWords(String s, int n) {
        StringBuilder res = new StringBuilder();
        if (s == null){
            return null;
        }
        for (int i = n; i < s.length(); i++) {
            res.append(s.charAt(i));
        }
        for (int i = 0; i < n; i++) {
            res.append(s.charAt(i));
        }
        return res.toString();
    }

一个字符串中找到最开始出现的不重复的字符

在字符串 s 中找出第一个只出现一次的字符。如果没有,返回一个单空格。 s 只包含小写字母。

示例:

s = "abaccdeff" 返回 "b"

s = "" 返回 " "

限制:

0 <= s 的长度 <= 50000

两次循环法

    /**
     * 循环一遍源字符串,按照字符出现的顺序,构建字典
     *
     * @param s
     * @return
     */
    public char firstUniqueChar(String s) {
        HashMap<Character, Boolean> dic = new HashMap<>();
        for (int i = 0; i < s.length(); i++) {
            char c = s.charAt(i);
            dic.put(c, !dic.containsKey(c));//已经存在了相同字符的话,置为false。
        }
        //因为要求s中第一个出现的不重复字符,所以要再遍历一遍s
        for (int i = 0; i < s.length(); i++) {
            char c  = s.charAt(i);
            if (dic.get(c)){
                return c;
            }
        }
        return "";
    }

改进

关于“第一个出现的不重复字符” ,字符串长度是无限的,但是组成字符串的小写字母是固定的不多于26个。如果选用的字典数据结构是有序存储的话,用就可以遍历一次字典取代遍历源字符串

  public char firstUniqChar(String s) {
        if (s == null){
            return ' ';
        }
        LinkedHashMap<Character, Boolean> dic = new LinkedHashMap<>();
        for (int i = 0; i < s.length(); i++) {
            char c = s.charAt(i);
            dic.put(c, !dic.containsKey(c));//已经存在了相同字符的话,置为false。
        }
        //字典是有序的,所以只要查找字典中 value 为true对应的key即可。
        for (Map.Entry<Character, Boolean> entry : dic.entrySet()) {
            if (entry.getValue()){
                return entry.getKey();
            }
        }
        return ' ';
    }

括号有效性问题

描述:

给定一个只包括 ‘(’,’)'的字符串,判断字符串是否有效。注:空字符串属于有效字符串

示例 1:输入: "(())"输出: true 实例 2: 输入: "())("输出: false 12345678

思路:

读到左括号则压栈,读到右括号,尝试与栈顶左括号进行匹配,匹配成功则弹出

   /**
     * 1. 括号匹配
     * 读到左括号则压栈,读到右括号,尝试与栈顶左括号进行匹配,匹配成功则弹出
     * [](){}
     * 判定条件:栈为空
     */
    public static boolean isParenthesesValid(String bracketsStr){
        Stack<Character> charStack = new Stack<>();
        char c = ' ';
        for (int i = 0; i < bracketsStr.length(); i++) {
            c = bracketsStr.charAt(i);
            if (c == '{' || c == '[' || c =='('){
                charStack.push(c);
            }else{
                if (charStack.empty()){
                    return false;
                }
                //check top char in stack
                char topChar = charStack.peek();
                //the target char match the top char
                char matchChar = ' ';
                if (topChar == '{'){
                    matchChar = '}';
                }else if (topChar == '['){
                    matchChar = ']';
                }else {
                    matchChar = ')';
                }

                if (c != matchChar){
                   break;
                }
                charStack.pop();
            }
        }

        if (charStack.empty()){
            return true;
        }
        return false;
    }

大数相加

给定两个字符串形式的非负整数 num1 和num2 ,计算它们的和。

提示:

num1 和num2 的长度都小于 5100 num1 和num2 都只包含数字 0-9 num1 和num2 都不包含任何前导零 你不能使用任何內建 BigInteger 库, 也不能直接将输入的字符串转换为整数形式

来源:力扣(LeetCode) 链接:https://leetcode-cn.com/problems/add-strings 著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

    /**
     * 大数相加
     * 模拟列竖式,双指针从两个串的末位同时向首部移动,同时一个变量记录是否有满10进位
     * 计算结果用StringBuilder 记录,结束后反转一下
     *
     * @param num1
     * @param num2
     * @return
     */
    public String addStrings(String num1, String num2) {
        int i = num1.length() - 1;
        int j = num2.length() - 1;
        int add = 0; //记录上一轮计算是否有进位
        StringBuilder ans = new StringBuilder();
        while (i >= 0 || j >= 0 || add > 0) {
            int x = i >= 0 ? num1.charAt(i) - '0' : 0;
            int y = j >= 0 ? num2.charAt(j) - '0' : 0;
            int sum = x + y + add;
            ans.append(sum % 10);
            add = sum / 10;
            i--;
            j--;
        }
        //计算过程是个十百千...的顺序,结果需要是从高到低的顺序
        ans.reverse();
        return ans.toString();
    }

36进制加法

​ 思想类似, 用0~9,a-z 共36位,满36进位

验证回文串

描述:

给定一个字符串,验证它是否是回文串,只考虑字母和数字字符,可以忽略字母的大小写。

示例 1:

输入: "A man, a plan, a canal: Panama"
输出: true

示例2:

输入: "race a car"
输出: false

思路:

一开始先建立两个指针,left 和 right , 让它们分别从字符的开头和结尾处开始遍历整个字符串。

如果遇到非字母数字的字符就跳过,继续往下找,直到找到下一个字母数字或者结束遍历,如果遇到大写字母,就将其转为小写。

当左右指针都找到字母数字时,可以进行比较的时候,比较这两个字符,如果相等,则两个指针向它们的前进方向挪动,然后继续比较下面两个分别找到的字母数字,若不相等,直接返回 false。

图片

    /**
     * 验证回文串
     * 双指针分别从头、尾同步向中间移动(只考虑字母和数字字符,跳过无效字符)
     * ,若两指针总指向想等的字符则为回文串;
     *
     * @param s
     * @return
     */
    public boolean isPalindrome(String s) {
        if (s.length() == 0)
            return true;
        int l = 0, r = s.length() - 1;
        while (l <= r) {
            char left = s.charAt(l);
            char right = s.charAt(r);
            if (!isValidChar(left)) {
                l++;
                continue;
            }
            if (!isValidChar(right)) {
                r--;
                continue;
            }
            if (Character.toLowerCase(left) == Character.toLowerCase(right)) {
                l++;
                r--;
                continue;
            }
            return false;
        }
        return true;
    }

    private boolean isValidChar(char c) {
        return c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z';
    }