LeetCode 初级算法之字符串(下),看看你都学会了吗?

3,107 阅读6分钟

本文正在参加「金石计划 . 瓜分6万现金大奖」

前言:最近自己也开始系统的刷面试题了,算法是过不去的坎,希望自己能够坚持下去✊,同行的朋友们共勉。

上篇请看👉《LeetCode 初级算法之字符串(上),看看你都学会了吗?》

题一:验证回文串

如果在将所有大写字符转换为小写字符、并移除所有非字母数字字符之后,短语正着读和反着读都一样。则可以认为该短语是一个 回文串

字母和数字都属于字母数字字符

给你一个字符串 s,如果它是 回文串 ,返回 true ;否则,返回 false。

示例 1:

输入: s = "A man, a plan, a canal: Panama"
输出:true
解释:"amanaplanacanalpanama" 是回文串

示例 2:

输入:s = "race a car"
输出:false
解释:"raceacar" 不是回文串

示例 3:

输入:s = " "
输出:true
解释:在移除非字母数字字符之后,s 是一个空字符串 "" 
由于空字符串正着反着读都一样,所以是回文串

解题思路:双指针、Reversed

回文串:字符对称出现; 基于这个特性,我们可以有两种做法:

  1. 使用双指针判断前后相等。
  2. 颠倒字符串判断是否相等;

解法一:双指针

双指针 i(➡️), j = n-i-1(⬅️)
判断两个指针指向的字符是否相同; 如果相同,则同时移动位置,i++、j--如果不相同,则立即跳出循环; 在判断相同之前,还需要处理字符的合法性,如果哪一个指针的字符不合法,则需要继续移位,直到找到合法的字符为止。

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

代码

func isPalindrome(_ s: String) -> Bool {
    let sList = Array(s)
    var res = true
    guard sList.count > 0 else { return res }
    var leftIndex = 0
    var rightIndex = sList.count - 1
    while leftIndex < rightIndex {
        while leftIndex < rightIndex {
            let lChar = sList[leftIndex]
            if String(lChar).isAlphanumeric { break }
            leftIndex += 1
        }
        while leftIndex < rightIndex {
            let rChar = sList[rightIndex]
            if String(rChar).isAlphanumeric { break }
            rightIndex -= 1
        }
        if sList[leftIndex].lowercased() != sList[rightIndex].lowercased() {
            res = false
            return res
        }
        leftIndex += 1
        rightIndex -= 1
    }
    return res
}

解法二:筛选 + 双指针

基于上一种解法,我们知道在判断字符串之前还需要验证字符串。如果字符串中的其他的字符(除字母和数字以外)比较多的话,那么解法一会在验证字符串上耗时较长。

所以解法二采用先筛选合法的字符串,再使用双指针遍历。

时间复杂度:O(n) n取决于s的长度
空间复杂度:O(n)

代码

func isPalindrome(_ s: String) -> Bool {
    var res = true
    guard s.count > 0 else { return res }
    var list = [String]()
    for char in s {
        if String(char).isAlphanumeric {
            list.append(String(char))
        }
    }

    var leftIndex = 0
    var rightIndex = list.count - 1
    while leftIndex < rightIndex {
        if list[leftIndex].lowercased() != list[rightIndex].lowercased() {
            res = false
            return res
        }
        leftIndex += 1
        rightIndex -= 1
    }
    return res
}

解法三:Reversed

有种用魔法打败魔法的感觉。

代码

func isPalindrome3(_ s: String) -> Bool {
    var res = true
    guard s.count > 0 else { return res }
    let str = s.matchAlphanumeric()
    let tempStr = String(str.reversed())
    if str != tempStr {
        res = false
    }
    return res
}

题二:字符串转换整数 (atoi)

请你来实现一个 myAtoi(string s) 函数,使其能将字符串转换成一个 32 位有符号整数(类似 C/C++ 中的 atoi 函数)。

函数 myAtoi(string s) 的算法如下:

读入字符串并丢弃无用的前导空格
检查下一个字符(假设还未到字符末尾)为正还是负号,读取该字符(如果有)。 确定最终结果是负数还是正数。 如果两者都不存在,则假定结果为正。
读入下一个字符,直到到达下一个非数字字符或到达输入的结尾。字符串的其余部分将被忽略。
将前面步骤读入的这些数字转换为整数(即,"123" -> 123, "0032" -> 32)。如果没有读入数字,则整数为 0 。必要时更改符号(从步骤 2 开始)。
如果整数数超过 32 位有符号整数范围 [−2^31^,  2^31^ − 1] ,需要截断这个整数,使其保持在这个范围内。具体来说,小于 −2^31^ 的整数应该被固定为 −2^31^ ,大于 2^31^ − 1 的整数应该被固定为 2^31^ − 1 。
返回整数作为最终结果。

示例 1:

输入:s = "42"
输出:42

示例 2:

输入:s = "   -42"
输出:-42

示例 3:

输入:s = "4193 with words"
输出:4193

解题思路:数学计算(考虑溢出)

字符串转换整数需要考虑到以下三点:

  • 先过滤空格
  • 识别 +、—
  • 过滤>9,<0的符号,以及数字溢出的处理;

代码

func myAtoi(_ s: String) -> Int {
    guard s.count > 0 else { return 0 }
    var res: Int = 0
    var index = 0
    var sign = 1
    let array = Array(s)
    /// 第一步
    while index < s.count, array[index] == " " {
        index+=1
    }

    if index == array.count { return Int(res) }
    /// 第二步
    if array[index] == "+" {
        index+=1
    } else if array[index] == "-"{
        sign = 0
        index+=1
    }
    /// 第三步
    while index < s.count {
        let char = array[index]
        if char > "9" || char < "0" {
            break
        }
        let charNum = (char.asciiValue ?? 0) - (Character("0").asciiValue ?? 0)
        if res > Int32.max / 10 || (res == Int32.max / 10 && charNum > Int32.max % 10) {
            res = Int(Int32.max)
            break
        }
        if res < Int32.min / 10 || (res == Int32.min / 10 && charNum > -(Int32.min % 10)) {
            res = Int(Int32.min)
            break
        }
        res = res * 10 + Int(charNum) * (sign == 1 ? 1 : -1)
        index+=1
    }
    return Int(res)
}

题三:实现 strStr()

给你两个字符串 haystack 和 needle ,请你在 haystack 字符串中找出 needle 字符串的第一个匹配项的下标(下标从 0 开始)。如果 needle 不是 haystack 的一部分,则返回  -1 。

示例 1:

输入:haystack = "sadbutsad", needle = "sad"
输出:0
解释:"sad" 在下标 06 处匹配
第一个匹配项的下标是 0 ,所以返回 0 

示例 2:

输入:haystack = "leetcode", needle = "leeto"
输出:-1
解释:"leeto" 没有在 "leetcode" 中出现,所以返回 -1 

解题思路: 匹配字符串

匹配字符串通俗来讲就是找到字串,而找字串的话,最简单的做法就是从两个字符串开头开始匹配,如果两个字符相同,则继续往后匹配,如果两个字符不匹配,则回溯继续找。

这种解法中,因为有回溯的过程,所以有一定的时间上的耗时。虽然可以简单的实现,但不是最优解。

如果 h[i] == n[j], i++, j++;
如果 h[i] != n[j], i=i-j+1, j=0;

代码

func strStr(_ haystack: String, _ needle: String) -> Int {
    let h = Array(haystack)
    let n = Array(needle)
    var res = -1
    var i = 0, j = 0
    while i < h.count && j < n.count {
        if h[i] == n[j] {
            i+=1; j+=1
        } else {
            i = i-j+1; j=0
        }
        if j == n.count {
            res = i-j
            break
        }
    }
    return res
}

题四:外观数列

给定一个正整数 n ,输出外观数列的第 n 项。

「外观数列」是一个整数序列,从数字 1 开始,序列中的每一项都是对前一项的描述。

你可以将其视作是由递归公式定义的数字字符串序列:
countAndSay(1) = "1"
countAndSay(n) 是对 countAndSay(n-1) 的描述,然后转换成另一个数字字符串。
 前五项如下:
1.     1
2.     11
3.     21
4.     1211
5.     111221

第一项是数字 1
描述前一项,这个数是 1 即 “ 一 个 1 ”,记作 "11"
描述前一项,这个数是 11 即 “ 二 个 1 ” ,记作 "21"
描述前一项,这个数是 21 即 “ 一 个 2 + 一 个 1 ” ,记作 "1211"
描述前一项,这个数是 1211 即 “ 一 个 1 + 一 个 2 + 二 个 1 ” ,记作 "111221"

解题思路:递归法

当后一项需要依赖前一项的结果时,可以使用递归法。思路其实就是先压栈,然后再依次出栈。 掌握了这个思路,将所有项都依次进栈,f(n)、f(n-1)、f(n-2)、...、f(1),描述完前一项之后,依次出栈f(1)、f(2)、f(3)、...、f(n)

入栈: f(n) -> f(n-1) -> f(n-2) -> ... -> f(1)

描述: 前项描述 + \(count)\(item)

出栈: f(n) <- f(n-1) <- f(n-2) <- ... <- f(1)

代码

func countAndSay(_ n: Int) -> String {
    if n == 1 { return "1" }
    let s = countAndSay(n-1)
    var count = 1
    var string = ""
    var item = s[s.index(s.startIndex, offsetBy: 0)]
    for i in 1..<s.count {
        let startIndex = s.index(s.startIndex, offsetBy: i)
        let char: Character = s[startIndex]
        if char == item {
            count += 1
        } else {
            string += ("\(count)\(item)")
            count = 1
            item = char
        }
    }
    return string + "\(count)\(item)"
}

题五:最长公共前缀

编写一个函数来查找字符串数组中的最长公共前缀。 如果不存在公共前缀,返回空字符串 ""。

示例 1:

输入: strs = ["flower","flow","flight"]
输出: "fl"

示例 2:

输入: strs = ["dog","racecar","car"]
输出: ""
解释: 输入不存在公共前缀

解题思路:横向查找、纵向查找

截屏2022-12-01 13.13.09.png

解法一:横向查找

横向查找是指依次遍历每个字符串,更新最长公共前缀。

代码

func longestCommonPrefix(_ strs: [String]) -> String {
    guard strs.count > 0 else { return "" }
    var str = strs[0]
    for i in 1..<strs.count {
        str = commonPrefix(str,strs[i])
        if str.count == 0 { break }
    }
    return str
}

func commonPrefix(_ str1: String, _ str2: String) -> String {
    let length = min(str1.count, str2.count)
    var index = 0
    while index < length {
        let char1 = str1[str1.index(str1.startIndex, offsetBy: index)]
        let char2 = str2[str2.index(str2.startIndex, offsetBy: index)]
        if char1 != char2 {
            break
        }
        index+=1
    }
    return String(str1[str1.startIndex..<str1.index(str1.startIndex, offsetBy: index)])
}

解法二:纵向查找

纵向查找是指从前往后遍历所有字符串的每一列,比较相同列上的字符是否相同,如果相同则继续对下一列进行比较,如果不相同则当前列不再属于公共前缀,当前列之前的部分为最长公共前缀。

代码

func longestCommonPrefix(_ strs: [String]) -> String {
    guard strs.count > 0 else { return "" }
    var str = strs[0]
    for i in 0..<str.count {
        var char = str[str.index(str.startIndex, offsetBy: i)]
        for j in 1..<strs.count {
            let subStrs = Array(strs[j])
            if i == subStrs.count || subStrs[i] != char {
                return String(str[str.startIndex..<str.index(str.startIndex, offsetBy: i)])
            }
        }
    }
    return strs[0]
}

小结

字符串篇的初级算法看看你们都学会了吗?总结下来,匹配字符串、反转字符串、转换数字、找子串、寻找前后缀等。针对不同的题型,我们可以使用不同的方法,频率出现较多的是双指针、哈希表。

大多时候,可以使用哈希表判断重复字符,使用双指针减少时间复杂带来的耗时。对于依赖前项的这种数列,可以使用递归法。

字符串:双指针、哈希表、集合、递归、横向查找、纵向查找