算法题 字符串的排列

567. 字符串的排列

问题描述

给你两个字符串 s1s2,写一个函数来判断 s2 是否包含 s1排列

换句话说,s1 的排列之一是 s2子串

示例

输入: s1 = "ab", s2 = "eidbaooo"
输出: true
解释: s2 包含 s1 的排列之一 ("ba").

输入: s1 = "ab", s2 = "eidboaoo"
输出: false

输入: s1 = "adc", s2 = "dcda"
输出: true
解释: s2 包含 s1 的排列之一 ("dca" 或 "cda").

算法思路

滑动窗口 + 字符频次匹配

  1. 如果 s2 包含 s1 的排列,则存在一个长度为 s1.length() 的子串,其字符频次与 s1 完全相同
  2. 使用固定大小的滑动窗口(窗口大小 = s1.length())
  3. 维护窗口内字符频次数组和 s1 的字符频次数组
  4. 当两个频次数组相等时,找到 s1 的排列
  5. 滑动窗口:移除左边界字符,添加右边界字符,更新频次

优化(单数组差值法)

  1. 使用一个数组记录 s1 频次与窗口频次的差值
  2. 维护一个计数器,记录差值为 0 的字符种类数
  3. 当计数器等于 26(或实际使用的字符种类数)时,找到排列
  4. 滑动时更新差值和计数器

代码实现

方法一:双数组比较法

class Solution {
    /**
     * 判断s2是否包含s1的排列 - 双数组比较法
     * 
     * @param s1 目标字符串
     * @param s2 主字符串
     * @return 如果s2包含s1的排列则返回true,否则返回false
     */
    public boolean checkInclusion(String s1, String s2) {
        // 边界条件检查
        if (s1 == null || s2 == null || s1.length() > s2.length()) {
            return false;
        }
        
        int[] s1Count = new int[26];    // s1中各字符的频次
        int[] windowCount = new int[26]; // 当前窗口中各字符的频次
        int s1Len = s1.length();
        
        // 初始化:计算s1的字符频次,并初始化第一个窗口
        for (int i = 0; i < s1Len; i++) {
            s1Count[s1.charAt(i) - 'a']++;
            windowCount[s2.charAt(i) - 'a']++;
        }
        
        // 检查第一个窗口
        if (Arrays.equals(s1Count, windowCount)) {
            return true;
        }
        
        // 滑动窗口:从第2个位置开始
        for (int i = s1Len; i < s2.length(); i++) {
            // 移除左边界字符(窗口左移)
            windowCount[s2.charAt(i - s1Len) - 'a']--;
            // 添加右边界字符
            windowCount[s2.charAt(i) - 'a']++;
            
            // 检查当前窗口
            if (Arrays.equals(s1Count, windowCount)) {
                return true;
            }
        }
        
        return false;
    }
}

方法二:单数组差值法(优化)

class Solution {
    /**
     * 判断s2是否包含s1的排列 - 单数组差值法(优化)
     * 使用一个数组记录差值,避免每次比较整个数组
     * 
     * @param s1 目标字符串
     * @param s2 主字符串
     * @return 如果s2包含s1的排列则返回true,否则返回false
     */
    public boolean checkInclusion(String s1, String s2) {
        // 边界条件检查
        if (s1 == null || s2 == null || s1.length() > s2.length()) {
            return false;
        }
        
        int[] diff = new int[26];  // 记录s1频次与窗口频次的差值
        int s1Len = s1.length();
        int count = 0;  // 记录差值为0的字符种类数
        
        // 初始化:计算初始差值
        for (int i = 0; i < s1Len; i++) {
            diff[s1.charAt(i) - 'a']++;
            diff[s2.charAt(i) - 'a']--;
        }
        
        // 统计初始差值为0的字符种类数
        for (int i = 0; i < 26; i++) {
            if (diff[i] == 0) {
                count++;
            }
        }
        
        // 检查第一个窗口
        if (count == 26) {
            return true;
        }
        
        // 滑动窗口
        for (int i = s1Len; i < s2.length(); i++) {
            // 准备移除的字符(左边界)
            char leftChar = s2.charAt(i - s1Len);
            // 准备添加的字符(右边界)
            char rightChar = s2.charAt(i);
            
            // 处理移除左边界字符
            if (diff[leftChar - 'a'] == 0) {
                count--;  // 移除前差值为0,移除后不为0
            }
            diff[leftChar - 'a']++;
            if (diff[leftChar - 'a'] == 0) {
                count++;  // 移除后差值变为0
            }
            
            // 处理添加右边界字符
            if (diff[rightChar - 'a'] == 0) {
                count--;  // 添加前差值为0,添加后不为0
            }
            diff[rightChar - 'a']--;
            if (diff[rightChar - 'a'] == 0) {
                count++;  // 添加后差值变为0
            }
            
            // 检查当前窗口
            if (count == 26) {
                return true;
            }
        }
        
        return false;
    }
}

方法三:简化版单数组法

class Solution {
    /**
     * 简化版单数组法
     * 直接维护目标频次,窗口移动时更新
     * 
     * @param s1 目标字符串
     * @param s2 主字符串
     * @return 如果s2包含s1的排列则返回true,否则返回false
     */
    public boolean checkInclusion(String s1, String s2) {
        // 边界条件检查
        if (s1 == null || s2 == null || s1.length() > s2.length()) {
            return false;
        }
        
        int[] target = new int[26];
        int s1Len = s1.length();
        
        // 初始化目标频次
        for (char c : s1.toCharArray()) {
            target[c - 'a']++;
        }
        
        // 初始化第一个窗口
        for (int i = 0; i < s1Len; i++) {
            target[s2.charAt(i) - 'a']--;
        }
        
        // 检查第一个窗口
        if (isAllZero(target)) {
            return true;
        }
        
        // 滑动窗口
        for (int i = s1Len; i < s2.length(); i++) {
            // 移除左边界字符
            target[s2.charAt(i - s1Len) - 'a']++;
            // 添加右边界字符
            target[s2.charAt(i) - 'a']--;
            
            // 检查当前窗口
            if (isAllZero(target)) {
                return true;
            }
        }
        
        return false;
    }
    
    /**
     * 检查数组是否全为0
     */
    private boolean isAllZero(int[] arr) {
        for (int val : arr) {
            if (val != 0) {
                return false;
            }
        }
        return true;
    }
}

算法分析

  • 时间复杂度
    • 方法一:O(n × 26) = O(n),每次比较数组需要O(26)
    • 方法二:O(n),每次滑动窗口操作是O(1)
    • 方法三:O(n × 26) = O(n),每次检查数组需要O(26)
  • 空间复杂度:O(1)
    • 所有方法都只使用固定大小的数组(26个元素)
  • 方法对比
    • 方法一:逻辑清晰,易于理解
    • 方法二:效率最高,避免了数组比较
    • 方法三:代码简洁,但效率略低于方法二

算法过程

输入:s1 = "ab", s2 = "eidbaooo"

方法一执行过程

  1. 初始化:
    • s1Count = [1,1,0,0,...] (a,b各1个)
    • 第一个窗口 “ei” → windowCount = [0,0,0,0,1,0,0,0,1,0,...] (e,i各1个)
    • Arrays.equals(s1Count, windowCount) 为 false
  2. i=2:窗口 “id”
    • 移除 ‘e’ → windowCount[e] = 0
    • 添加 ‘d’ → windowCount[d] = 1
    • windowCount = [0,0,0,1,0,0,0,0,1,0,...]s1Count → false
  3. i=3:窗口 “db”
    • 移除 ‘i’ → windowCount[i] = 0
    • 添加 ‘b’ → windowCount[b] = 1
    • windowCount = [0,1,0,1,0,0,0,0,0,0,...]s1Count → false
  4. i=4:窗口 “ba”
    • 移除 ‘d’ → windowCount[d] = 0
    • 添加 ‘a’ → windowCount[a] = 1
    • windowCount = [1,1,0,0,0,0,0,0,0,0,...] = s1Count → return true

测试用例

public static void main(String[] args) {
    Solution solution = new Solution();
    
    // 测试用例1:标准示例
    System.out.println("Test 1: " + solution.checkInclusion("ab", "eidbaooo")); // true
    
    // 测试用例2:标准示例
    System.out.println("Test 2: " + solution.checkInclusion("ab", "eidboaoo")); // false
    
    // 测试用例3:包含排列
    System.out.println("Test 3: " + solution.checkInclusion("adc", "dcda")); // true
    
    // 测试用例4:相同字符串
    System.out.println("Test 4: " + solution.checkInclusion("abc", "abc")); // true
    
    // 测试用例5:s1比s2长
    System.out.println("Test 5: " + solution.checkInclusion("abcd", "abc")); // false
    
    // 测试用例6:空字符串
    System.out.println("Test 6: " + solution.checkInclusion("", "abc")); // true (空字符串的排列是空字符串)
    System.out.println("Test 7: " + solution.checkInclusion("a", "")); // false
    
    // 测试用例7:单字符
    System.out.println("Test 8: " + solution.checkInclusion("a", "a")); // true
    System.out.println("Test 9: " + solution.checkInclusion("a", "b")); // false
    
    // 测试用例8:重复字符
    System.out.println("Test 10: " + solution.checkInclusion("aab", "eidbaaoo")); // true
    
    // 测试用例9:无匹配
    System.out.println("Test 11: " + solution.checkInclusion("hello", "ooolleoooleh")); // false
    
    // 测试用例10:长字符串
    System.out.println("Test 12: " + solution.checkInclusion("abc", "ccccbbbbaaaa")); // false
}

关键点

  1. 排列定义

    • 两个字符串包含相同的字符,且每个字符的出现次数相同
    • 顺序可以不同
  2. 滑动窗口大小

    • 固定大小 = s1.length()
    • 只有长度相等的子串才可能是排列
  3. 字符频次匹配

    • 核心思想:排列 ⇔ 字符频次完全相同
    • 使用数组记录26个小写字母的频次
  4. 优化

    • 方法二避免了每次比较整个数组,通过维护计数器提高效率
    • 滑动窗口时,只需更新移除和添加的字符频次
  5. 边界处理

    • s1为null或长度大于s2时,直接返回false
    • s1为空字符串时,应返回true(空字符串的排列是空字符串,任何字符串都包含空字符串)

常见问题

  1. 为什么方法二中要检查 diff[char] == 0 前后的情况?

    • 因为只有当差值从0变为非0,或从非0变为0时,才会影响计数器
    • 如果差值从1变为2,或从-1变为-2,计数器不需要变化
  2. 如果字符串包含大写字母怎么办?

    • 题目说明只包含小写字母
    • 如果需要处理大小写,可以扩展数组大小到52,或统一转换为小写
  3. 方法二中的 count == 26 是否总是成立?

    • 是的,因为数组大小为26,当所有位置差值都为0时,count=26
    • 即使某些字母在s1和窗口中都没出现(差值为0),也会计入count
  4. 与438题的区别是什么?

    • 438题要求返回所有起始索引,本题只需判断是否存在
    • 438题需要收集结果,本题找到即返回true
    • 算法核心完全相同,都是滑动窗口+字符频次匹配
  5. 如何扩展支持任意字符(包括Unicode)?

    • 使用HashMap代替数组
    • 记录实际出现的字符种类,而不是固定26个
    • 判断条件改为 count == map.size()(实际使用的字符种类数)
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值