【LeetCode】91. 解码方法

文章目录

91. 解码方法

题目描述

一条包含字母 A-Z 的消息通过以下映射进行了 编码 :

"1" -> 'A'

"2" -> 'B'

...

"25" -> 'Y'

"26" -> 'Z'

然而,在 解码 已编码的消息时,你意识到有许多不同的方式来解码,因为有些编码被包含在其它编码当中("2" 和 "5" 与 "25")。

例如,"11106" 可以映射为:

"AAJF" ,将消息分组为 (1, 1, 10, 6)

"KJF" ,将消息分组为 (11, 10, 6)

消息不能分组为 (1, 11, 06) ,因为 "06" 不是一个合法编码(只有 "6" 是合法的)。

注意,可能存在无法解码的字符串。

给你一个只含数字的 非空 字符串 s ,请计算并返回 解码 方法的 总数 。如果没有合法的方式解码整个字符串,返回 0。

题目数据保证答案肯定是一个 32 位 的整数。

示例 1:

输入:s = "12"

输出:2

解释:它可以解码为 "AB"(1 2)或者 "L"(12)。

示例 2:

输入:s = "226"

输出:3

解释:它可以解码为 "BZ" (2 26), "VF" (22 6), 或者 "BBF" (2 2 6) 。

示例 3:

输入:s = "06"

输出:0

解释:"06" 无法映射到 "F" ,因为存在前导零("6" 和 "06" 并不等价)。

提示:

  • 1 <= s.length <= 100
  • s 只包含数字,并且可能包含前导零。

解题思路

问题深度分析

这是经典的动态规划 问题,也是字符串解码 的经典应用。核心在于状态转移,在O(n)时间内计算所有可能的解码方法数。

问题本质

给定只含数字的字符串s,计算解码方法的总数。每个数字1-9对应A-I,10-26对应J-Z。

核心思想

动态规划 + 状态转移

  1. 状态定义:dp[i]表示前i个字符的解码方法数
  2. 状态转移:考虑单字符和双字符两种解码方式
  3. 边界处理:处理前导零和无效编码
  4. 优化技巧:滚动数组优化空间复杂度

关键技巧

  • 单字符解码:s[i] != '0'时,dp[i] += dp[i-1]
  • 双字符解码:s[i-1:i+1]在10-26范围内时,dp[i] += dp[i-2]
  • 前导零处理:'0'不能单独解码
  • 无效编码处理:超出26范围的编码无效
关键难点分析

难点1:状态转移的理解

  • 需要理解单字符和双字符两种解码方式
  • 状态转移方程的正确推导
  • 边界条件的处理

难点2:前导零的处理

  • '0'不能单独解码
  • '06'、'00'等无效编码的处理
  • 边界情况的判断

难点3:双字符编码的验证

  • 需要验证s[i-1:i+1]是否在10-26范围内
  • 处理'0'开头的双字符编码
  • 避免越界访问
典型情况分析

情况1:一般情况

复制代码
s = "226"
dp[0] = 1 (空字符串)
dp[1] = 1 (2 -> B)
dp[2] = dp[1] + dp[0] = 1 + 1 = 2 (2,2 或 22)
dp[3] = dp[2] + dp[1] = 2 + 1 = 3 (2,2,6 或 2,26 或 22,6)

结果: 3

情况2:包含0

复制代码
s = "06"
dp[0] = 1
dp[1] = 0 (0不能单独解码)
dp[2] = 0 (06不能解码)

结果: 0

情况3:单字符

复制代码
s = "12"
dp[0] = 1
dp[1] = 1 (1 -> A)
dp[2] = dp[1] + dp[0] = 1 + 1 = 2 (1,2 或 12)

结果: 2

情况4:全1

复制代码
s = "111"
dp[0] = 1
dp[1] = 1 (1 -> A)
dp[2] = dp[1] + dp[0] = 1 + 1 = 2 (1,1 或 11)
dp[3] = dp[2] + dp[1] = 2 + 1 = 3 (1,1,1 或 1,11 或 11,1)

结果: 3
算法对比
算法 时间复杂度 空间复杂度 特点
动态规划 O(n) O(n) 最优解法
滚动数组DP O(n) O(1) 空间优化
递归+记忆化 O(n) O(n) 逻辑清晰
迭代DP O(n) O(n) 避免递归

注:n为字符串长度

算法流程图

主算法流程(动态规划)

开始: 字符串s 初始化DP数组 处理边界情况 遍历字符串 单字符解码 双字符解码 更新DP状态 继续下一个字符 返回结果

状态转移流程
graph TD A[当前位置i] --> B{s[i] == '0'?} B -->|是| C[跳过单字符解码] B -->|否| D[单字符解码: dp[i] += dp[i-1]] C --> E{i >= 1?} D --> E E -->|是| F{双字符有效?} E -->|否| G[结束] F -->|是| H[双字符解码: dp[i] += dp[i-2]] F -->|否| I[跳过双字符解码] H --> G I --> G
解码过程可视化

DP状态 解码方式 字符串示例 dp[0] = 1 dp[1] = 1 dp[2] = 2 dp[3] = 3 2,2,6 -> BBF 2,26 -> BZ 22,6 -> VF 226

复杂度分析

时间复杂度详解

动态规划算法:O(n)

  • 遍历字符串一次,时间复杂度O(n)
  • 每次状态转移的时间复杂度O(1)
  • 总时间:O(n)

递归算法:O(n)

  • 每个位置最多访问一次
  • 记忆化避免重复计算
  • 总时间:O(n)
空间复杂度详解

动态规划算法:O(n)

  • DP数组长度为n+1
  • 总空间:O(n)

滚动数组优化:O(1)

  • 只使用两个变量存储状态
  • 总空间:O(1)

关键优化技巧

技巧1:动态规划(最优解法)
go 复制代码
func numDecodings(s string) int {
    n := len(s)
    if n == 0 || s[0] == '0' {
        return 0
    }
    
    dp := make([]int, n+1)
    dp[0] = 1
    dp[1] = 1
    
    for i := 2; i <= n; i++ {
        // 单字符解码
        if s[i-1] != '0' {
            dp[i] += dp[i-1]
        }
        
        // 双字符解码
        if s[i-2] == '1' || (s[i-2] == '2' && s[i-1] <= '6') {
            dp[i] += dp[i-2]
        }
    }
    
    return dp[n]
}

优势

  • 时间复杂度:O(n)
  • 空间复杂度:O(n)
  • 逻辑清晰,易于理解
技巧2:滚动数组优化
go 复制代码
func numDecodings(s string) int {
    n := len(s)
    if n == 0 || s[0] == '0' {
        return 0
    }
    
    prev2 := 1 // dp[i-2]
    prev1 := 1 // dp[i-1]
    
    for i := 2; i <= n; i++ {
        curr := 0
        
        // 单字符解码
        if s[i-1] != '0' {
            curr += prev1
        }
        
        // 双字符解码
        if s[i-2] == '1' || (s[i-2] == '2' && s[i-1] <= '6') {
            curr += prev2
        }
        
        prev2 = prev1
        prev1 = curr
    }
    
    return prev1
}

特点:使用滚动数组,空间复杂度O(1)

技巧3:递归+记忆化
go 复制代码
func numDecodings(s string) int {
    memo := make(map[int]int)
    return helper(s, 0, memo)
}

func helper(s string, index int, memo map[int]int) int {
    if index == len(s) {
        return 1
    }
    
    if s[index] == '0' {
        return 0
    }
    
    if val, ok := memo[index]; ok {
        return val
    }
    
    result := helper(s, index+1, memo)
    
    if index+1 < len(s) {
        twoDigit := int(s[index]-'0')*10 + int(s[index+1]-'0')
        if twoDigit <= 26 {
            result += helper(s, index+2, memo)
        }
    }
    
    memo[index] = result
    return result
}

特点:使用递归DFS,记忆化避免重复计算

技巧4:迭代DP(简化版)
go 复制代码
func numDecodings(s string) int {
    n := len(s)
    if n == 0 || s[0] == '0' {
        return 0
    }
    
    dp := make([]int, n+1)
    dp[0] = 1
    dp[1] = 1
    
    for i := 2; i <= n; i++ {
        dp[i] = 0
        
        // 单字符解码
        if s[i-1] != '0' {
            dp[i] += dp[i-1]
        }
        
        // 双字符解码
        if s[i-2] == '1' || (s[i-2] == '2' && s[i-1] <= '6') {
            dp[i] += dp[i-2]
        }
    }
    
    return dp[n]
}

特点:使用迭代方法,避免递归

边界情况处理

  1. 空字符串:返回0
  2. 前导零:s[0] == '0'时返回0
  3. 单字符:直接判断是否为'0'
  4. 无效编码:超出26范围的双字符编码

测试用例设计

基础测试
复制代码
输入: s = "226"
输出: 3
说明: 一般情况
简单情况
复制代码
输入: s = "12"
输出: 2
说明: 单字符和双字符解码
特殊情况
复制代码
输入: s = "06"
输出: 0
说明: 前导零
边界情况
复制代码
输入: s = "0"
输出: 0
说明: 单字符0
复杂情况
复制代码
输入: s = "11106"
输出: 2
说明: 包含0的复杂情况

常见错误与陷阱

错误1:前导零处理错误
go 复制代码
// ❌ 错误:没有处理前导零
func numDecodings(s string) int {
    dp := make([]int, len(s)+1)
    dp[0] = 1
    // 直接开始DP,没有检查s[0] == '0'
}

// ✅ 正确:处理前导零
func numDecodings(s string) int {
    if len(s) == 0 || s[0] == '0' {
        return 0
    }
    // 然后开始DP
}
错误2:双字符编码验证错误
go 复制代码
// ❌ 错误:没有验证双字符编码
if s[i-2] == '1' || s[i-2] == '2' {
    dp[i] += dp[i-2]
}

// ✅ 正确:验证双字符编码
if s[i-2] == '1' || (s[i-2] == '2' && s[i-1] <= '6') {
    dp[i] += dp[i-2]
}
错误3:边界条件处理错误
go 复制代码
// ❌ 错误:没有处理边界条件
for i := 1; i <= n; i++ {
    // 直接访问s[i-1],可能越界
}

// ✅ 正确:处理边界条件
for i := 2; i <= n; i++ {
    // 确保i-2 >= 0
}

实战技巧总结

  1. 状态定义:dp[i]表示前i个字符的解码方法数
  2. 状态转移:单字符和双字符两种方式
  3. 边界处理:前导零和无效编码
  4. 空间优化:滚动数组优化
  5. 状态管理:正确维护DP状态

进阶扩展

扩展1:返回所有解码方案
go 复制代码
func getAllDecodings(s string) []string {
    // 返回所有可能的解码字符串
    // ...
}
扩展2:解码特定位置
go 复制代码
func decodeAtPosition(s string, pos int) int {
    // 返回解码到位置pos的方法数
    // ...
}
扩展3:支持更多编码
go 复制代码
func numDecodingsExtended(s string, maxCode int) int {
    // 支持1-maxCode的编码范围
    // ...
}

应用场景

  1. 密码学:消息解码和加密
  2. 通信协议:数据编码传输
  3. 算法竞赛:动态规划经典应用
  4. 系统设计:错误恢复机制
  5. 数据处理:字符串解析

代码实现

本题提供了四种不同的解法,重点掌握动态规划算法。

测试结果

测试用例 动态规划 滚动数组DP 递归+记忆化 迭代DP
基础测试
简单情况
特殊情况
边界情况

核心收获

  1. 动态规划:字符串解码的经典应用
  2. 状态转移:单字符和双字符解码
  3. 边界处理:前导零和无效编码
  4. 空间优化:滚动数组技巧
  5. 状态管理:正确维护DP状态

应用拓展

  • 动态规划基础
  • 字符串处理技术
  • 状态转移设计
  • 边界条件处理
  • 算法优化技巧

完整题解代码

go 复制代码
package main

import (
	"fmt"
)

// =========================== 方法一:动态规划(最优解法) ===========================

func numDecodings(s string) int {
	n := len(s)
	if n == 0 || s[0] == '0' {
		return 0
	}

	dp := make([]int, n+1)
	dp[0] = 1
	dp[1] = 1

	for i := 2; i <= n; i++ {
		// 单字符解码
		if s[i-1] != '0' {
			dp[i] += dp[i-1]
		}

		// 双字符解码
		if s[i-2] == '1' || (s[i-2] == '2' && s[i-1] <= '6') {
			dp[i] += dp[i-2]
		}
	}

	return dp[n]
}

// =========================== 方法二:滚动数组优化 ===========================

func numDecodings2(s string) int {
	n := len(s)
	if n == 0 || s[0] == '0' {
		return 0
	}

	prev2 := 1 // dp[i-2]
	prev1 := 1 // dp[i-1]

	for i := 2; i <= n; i++ {
		curr := 0

		// 单字符解码
		if s[i-1] != '0' {
			curr += prev1
		}

		// 双字符解码
		if s[i-2] == '1' || (s[i-2] == '2' && s[i-1] <= '6') {
			curr += prev2
		}

		prev2 = prev1
		prev1 = curr
	}

	return prev1
}

// =========================== 方法三:递归+记忆化 ===========================

func numDecodings3(s string) int {
	if len(s) == 0 {
		return 0
	}
	memo := make(map[int]int)
	return helper(s, 0, memo)
}

func helper(s string, index int, memo map[int]int) int {
	if index == len(s) {
		return 1
	}

	if s[index] == '0' {
		return 0
	}

	if val, ok := memo[index]; ok {
		return val
	}

	result := helper(s, index+1, memo)

	if index+1 < len(s) {
		twoDigit := int(s[index]-'0')*10 + int(s[index+1]-'0')
		if twoDigit <= 26 {
			result += helper(s, index+2, memo)
		}
	}

	memo[index] = result
	return result
}

// =========================== 方法四:迭代DP(简化版) ===========================

func numDecodings4(s string) int {
	n := len(s)
	if n == 0 || s[0] == '0' {
		return 0
	}

	dp := make([]int, n+1)
	dp[0] = 1
	dp[1] = 1

	for i := 2; i <= n; i++ {
		dp[i] = 0

		// 单字符解码
		if s[i-1] != '0' {
			dp[i] += dp[i-1]
		}

		// 双字符解码
		if s[i-2] == '1' || (s[i-2] == '2' && s[i-1] <= '6') {
			dp[i] += dp[i-2]
		}
	}

	return dp[n]
}

// =========================== 测试代码 ===========================

func main() {
	fmt.Println("=== LeetCode 91: 解码方法 ===\n")

	testCases := []struct {
		name     string
		s        string
		expected int
	}{
		{
			name:     "Test1: Basic case",
			s:        "226",
			expected: 3,
		},
		{
			name:     "Test2: Simple case",
			s:        "12",
			expected: 2,
		},
		{
			name:     "Test3: Leading zero",
			s:        "06",
			expected: 0,
		},
		{
			name:     "Test4: Single zero",
			s:        "0",
			expected: 0,
		},
		{
			name:     "Test5: Complex case",
			s:        "11106",
			expected: 2,
		},
		{
			name:     "Test6: All ones",
			s:        "111",
			expected: 3,
		},
		{
			name:     "Test7: Large number",
			s:        "27",
			expected: 1,
		},
		{
			name:     "Test8: Empty string",
			s:        "",
			expected: 0,
		},
		{
			name:     "Test9: Single digit",
			s:        "1",
			expected: 1,
		},
		{
			name:     "Test10: Two zeros",
			s:        "00",
			expected: 0,
		},
	}

	methods := map[string]func(string) int{
		"动态规划(最优解法)": numDecodings,
		"滚动数组优化":     numDecodings2,
		"递归+记忆化":     numDecodings3,
		"迭代DP(简化版)":  numDecodings4,
	}

	for name, method := range methods {
		fmt.Printf("方法:%s\n", name)
		passCount := 0
		for i, tt := range testCases {
			got := method(tt.s)

			// 验证结果是否正确
			valid := got == tt.expected
			status := "✅"
			if !valid {
				status = "❌"
			} else {
				passCount++
			}
			fmt.Printf("  测试%d: %s\n", i+1, status)
			if status == "❌" {
				fmt.Printf("    输入: %s\n", tt.s)
				fmt.Printf("    输出: %d\n", got)
				fmt.Printf("    期望: %d\n", tt.expected)
			}
		}
		fmt.Printf("  通过: %d/%d\n\n", passCount, len(testCases))
	}
}
相关推荐
大数据张老师3 小时前
数据结构——内部排序算法的选择和应用
数据结构·算法·排序算法
JohnYan3 小时前
微软验证器-验证ID功能初体验
后端·算法·安全
路弥行至4 小时前
C语言入门教程 | 第七讲:函数和程序结构完全指南
c语言·经验分享·笔记·其他·算法·课程设计·入门教程
Xの哲學4 小时前
Linux ioctl 深度剖析:从原理到实践
linux·网络·算法·架构·边缘计算
隐语SecretFlow4 小时前
隐语SecreFlow:如何全面提升MPC多方安全学习的性能?
算法
王国强20094 小时前
什么是算法复杂度?
算法
夏鹏今天学习了吗4 小时前
【LeetCode热题100(54/100)】全排列
算法·leetcode·深度优先
緈福的街口4 小时前
gps的定位图,在车的位置去寻找周围20x20的区域,怎么确定周围有多少辆车,使用什么数据结构
数据结构·算法
江塘5 小时前
机器学习-KNN算法实战及模型评估可视化(C++/Python实现)
开发语言·c++·人工智能·python·算法·机器学习