剑指OfferJZ38字符串的排列(next_permutation实现、康托展开)

剑指offer JZ38

题目描述

输入一个长度为 n n n 字符串,打印出该字符串中字符的所有排列,你可以以任意顺序返回这个字符串数组。

例如:输入字符串 ABC \text{ABC} ABC ,则输出由字符 A,B,C \text{A,B,C} A,B,C 所能排列出来的所有字符串 ABC \text{ABC} ABC , ACB \text{ACB} ACB , BAC \text{BAC} BAC , BCA \text{BCA} BCA , CBA \text{CBA} CBA CAB \text{CAB} CAB

数据范围 n < 10 n < 10 n<10

要求:空间复杂度 O ( n ! ) O(n!) O(n!),时间复杂度 O ( n ! ) O(n!) O(n!)

输入描述

输入一个字符串,长度不超过 10 10 10 , 字符只包括大小写字母。

输入:

"ab"

返回值:

["ab","ba"]

返回[“ba”,“ab”]也是正确的

输入:

"aab"

返回值:

["aab","aba","baa"]

输入:

"abc"

返回值:

["abc","acb","bac","bca","cab","cba"]

输入:

""

返回值:

[]

分析

首先说明一下,这道题是牛客上刷的笔试题,核心代码模式,不是ACM模式。也就是说,它会给你下面这样一个接口:

话说牛客这在线编辑器做的还是不错的,可以设置 Vim 编辑,还自带 solarized light主题。虽然我更想要 solarized dark,大概是下面这个样子:

好吧下面分析这道题。

首先想到的就是 STL 中的 next_permutation \text{next\_permutation} next_permutation,直接获取字典序中下一个排列。使用之前需要对原序列排序,这样才能获取所有的排列。

这个方法是不需要判重的,因为 next_permutation \text{next\_permutation} next_permutation 它获取下一个排列,自然与上一个不重复。

class Solution {
public:
    vector<string> Permutation(string str) {
        vector<string> ret;
        sort(str.begin(),str.end());
        do{
            ret.push_back(str);
        }while(next_permutation(str.begin(),str.end()));
        return ret;
    }
};

另外提一下, next_permutation \text{next\_permutation} next_permutation 调用一次的复杂度最优为 O ( 1 ) O(1) O(1),最差为 O ( n ) O(n) O(n) n n n 为序列的长度。但是如果连续调用若干次该函数,取遍所有的排列,那么它的均摊时间复杂度为 O ( 1 ) O(1) O(1)

至于为啥均摊O(1),这个我不会证明,只有一个感性的认知。具体每次调用next_permutation复杂度是多少,它直接受序列从尾往前非递减子序列长度影响。我举了几个全排列的例子,发现所有排列中这个子序列总长度是一个 1 + 1 2 \frac{1}{2} 21 + 1 4 \frac{1}{4} 41 + … 的模型,也就是均摊下来每个排列尾部这个序列长度大概是2,是个小常数。后面会放出next_permutation源码与我写的my_nextPermutation,大家可以再琢磨琢磨。

提交了上面的代码后,我又想到逆康托展开。也可以处理排列问题,本着复习的目的,用逆康托展开做了遍:

按我的理解,逆康托展开它和进制转换有点相似 好吧也不是很相似。例如:

排列映射的数字
1230
1321
2132
2313
3124
3215

那么假设已知 “映射的数字” 是 3 3 3 ,如何得到原排列?

首先确定第一个数字,因为后两位数字有 2 ! 2! 2! 种组合, ⌊ 3 2 ! ⌋ = 1 \lfloor \frac{3}{2!} \rfloor=1 2!3=1,所以第一个数字要取当前能取的 “第1小” 的数字。当前 1 , 2 , 3 1,2,3 1,2,3 都可以取,那么 “第1小” 的数字就是 ‘2’(这里的第1小指的是下标,第0小的数字才是’1’),排列的第一位数字得到。

那么现在递归一下,相当于在 1,3 这两个数字的排列里面,找 “映射的数字” 是 3 % 2 ! = 1 3\%2!=1 3%2!=1 的原排列。

那么现在要确定排列的第二位数字,后 1 1 1 位数字有 1 ! 1! 1! 种组合, ⌊ 1 1 ! ⌋ = 1 \lfloor \frac{1}{1!} \rfloor=1 1!1=1,现在又要取当前可取的数字中 “第1小” 的数字。当前可以取 1 , 3 1,3 1,3 ,那么这一位就取 ‘3’.

继续递归,在 1 这个数字的排列里面(只有它自己一种排列),找 “映射的数字” 是 1 % 1 ! = 0 1\%1!=0 1%1!=0 的原排列。

现在要确定排列的第三位数字,后 0 0 0 位数字有 0 ! = 1 0!=1 0!=1 种组合, ⌊ 0 0 ! ⌋ = 0 \lfloor \frac{0}{0!} \rfloor=0 0!0=0,那么取当前可取的数字种 “第0小” 的数字。所以取 ‘1’.

逆康托展开大概就是这么个过程吧。可能我分析的还不如直接看代码琢磨

当然也有(正)康托展开,将一个排列映射为一个数字。正展开和逆展开原理一样,大家可以自己探索探索。

class Solution {
public:
    int f[15]={1};
    unordered_set<string> st;

    string get(int x,int n,string str){
        string ret;
        for(int i=n;i>=1;i--){
            int t=x/f[i-1];
            ret.push_back(str[t]);
            str.erase(str.begin()+t);
            x%=f[i-1];
        }
        return ret;
    }

    vector<string> Permutation(string str) {
        vector<string> ret;
        for(int i=1;i<=10;i++) f[i]=f[i-1]*i;
        for(int i=0;i<f[str.size()];i++){
            string t=get(i,str.size(),str);
            if(!st.count(t)) st.insert(t),ret.push_back(t);
        }
        return ret;
    }
};

逆康托展开一次的复杂度是 O ( n 2 ) O(n^2) O(n2),这样看来比起 next_permutation \text{next\_permutation} next_permutation 差远了。但是康托展开有另外的用途,例如给一个序列 s s s,求字典序排在它之后 m ≤ 1 0 18 m\le10^{18} m1018 个的序列。这个问题就更适合用康托展开来解决。

然后我在牛客题解中又看到有人手写了个 my_nextPermutation \text{my\_nextPermutation} my_nextPermutation 来做这道题,妙啊。其实我一直很好奇 STL 中 next_permutation \text{next\_permutation} next_permutation 的实现,看了写题解那位大佬的代码,并不是很懂。在被室友拉出去买饭排队的途中,决定自己也动动脑,然后发现所有求下一个排列的操作,无非就是个 1 , 4 , 3 , 2 1,4,3,2 1,4,3,2 的模型。尾部往前是一个非递减的序列,直到某处出现递减,然后 swap \text{swap} swap , reverse \text{reverse} reverse 就行了。

吃完饭后写了下面的代码:

class Solution {
public:
    bool my_nextPermutation(string &s){
        int l=s.size()-2,r=s.size()-1;
        while(l>=0&&s[l]>=s[l+1]) l--;
        if(l<0) return false;
        while(s[r]<=s[l]) r--; 
        swap(s[l],s[r]);
        reverse(s.begin()+l+1,s.end());
        return true;
    }
    
    vector<string> Permutation(string str) {
        vector<string> ret;
        sort(str.begin(),str.end());
        do{
            ret.push_back(str);
        }while(my_nextPermutation(str));
        return ret;
    }
};

其实在敲代码的过程中,我怀疑可能出现 reverse(itl,itr)itr<itl 的问题,但后来发现根本没有这种情况。

我估计即使出现这种问题,reverse 也不会出错。于是顺带搜了搜 reverse 的源码:

template <class _RandomAccessIter>
_STLP_INLINE_LOOP void
__reverse(_RandomAccessIter __first, _RandomAccessIter __last, const random_access_iterator_tag &) {
  for (; __first < __last; ++__first)
    _STLP_STD::iter_swap(__first, --__last);
}

果然如果 itr<itl ,根本不会进循环,但是这仅适用于随机访问迭代器。该函数还有如下的重载版本:

template <class _BidirectionalIter>
_STLP_INLINE_LOOP void
__reverse(_BidirectionalIter __first, _BidirectionalIter __last, const bidirectional_iterator_tag &) {
  for (; __first != __last && __first != --__last; ++__first)
    _STLP_STD::iter_swap(__first,__last);
}

那么对于地址不是连续存放的容器,确实应该注意这个问题。

再看看代码逻辑,STL 源码果然不寻常,仅仅是简单的 reverse 写的都这么有水准。

既然都手搓了个 my_nextPermutation \text{my\_nextPermutation} my_nextPermutation ,那肯定是要对比一下 STL 源码来看看:

template<typename _BidirectionalIterator, typename _Compare>
    bool
    __next_permutation(_BidirectionalIterator __first,
		       _BidirectionalIterator __last, _Compare __comp)
    {
      if (__first == __last)                      // 传入内容为空,直接返回false
	return false;
      _BidirectionalIterator __i = __first;       // 仅要求双向迭代器
      ++__i;                                      
      if (__i == __last)                          // 仅包含一个元素,返回false
	return false;
      __i = __last;
      --__i;                                      // __i 指向最后一个元素

      for(;;)
	{
	  _BidirectionalIterator __ii = __i;            //  
	  --__i;										// __i + 1 == __i , 这两个迭代器负责找到第一个非递增点
	  if (__comp(__i, __ii))						// 获得非递增点
	    {
	      _BidirectionalIterator __j = __last;      
	      while (!__comp(__i, --__j))               // 获取第一个比 __i 大的元素
		{}
	      std::iter_swap(__i, __j);					// 交换
	      std::__reverse(__ii, __last,				// 逆序
			     std::__iterator_category(__first));
	      return true;
	    }
	  if (__i == __first)							// 查找到了first, 此时所有元素全部递增,所以此时序列最大
	    {
	      std::__reverse(__first, __last,
			     std::__iterator_category(__first)); // 最大序列逆序,会成为最小序列
	      return false;
	    }
	}
}

总的来说逻辑和上面我的实现一模一样,先倒着找到第一个非递增点 i i i,再倒着找到第一个大于 ∗ i *i i ∗ j *j jswap(i,j)reverse(i+1,end());

最后呢,我觉得这道题的本意还是希望我们用 dfs \text{dfs} dfs 来做。如果用 bool \text{bool} bool 数组记录每个元素有没有使用过,那么复杂度 O ( n n ) O(n^n) O(nn)

还有更好的方法,用 swap \text{swap} swap 将已经使用过的元素全部移动到序列前 x x x 位,这样就不用每次都扫描全部的序列了。代码如下:

一定要用 unordered_map \text{unordered\_map} unordered_map 判个重。

class Solution {
public:
    
    unordered_set<string> st;
    int len;
    
    void dfs(string& s,int step){
        if(step>=len) return (void)st.insert(s);
        for(int i=step;i<len;i++){
            swap(s[step],s[i]);
            dfs(s,step+1);
            swap(s[step],s[i]);
        }
    }
    
    vector<string> Permutation(string str) {
        len=str.size();
        dfs(str,0);
        return vector<string>(st.begin(),st.end()); 
    }
};

在做这道题之前,我是万万没想到一道递归的中等题能牵扯出这么多东西。在做题的过程中,不断联系到其他的知识点,也就依次把它们复习一遍,同时也学到了很多新的知识。例如 next_permutation \text{next\_permutation} next_permutation 的实现与复杂度也是今天才了解。

最近几个月开始准备找工作,不刷 ACM 博客也就没啥可更新的,总不能做道反转链表的题都发文章吧。当然近期也积累了很多面试有关的笔记,但我写的笔记仅仅是作为自己复习时的备忘录,里面的内容比较杂,不全面,甚至不正确,也就没发在博客上。好吧希望之后复习顺利,能做自己感兴趣的工作。

参考资料

牛客题解,递归做法

牛客题解,next_permutation实现

next_permutation源码伪码分析

STL中reverse源码

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
next_permutation函数是C++ STL提供的一个函数,用于求一个序列的下一个字典序排列。它可以通过包含头文件<algorithm>来使用。使用方法如下: ```cpp #include <algorithm> using namespace std; int main() { int a\[\] = {1, 2, 3}; do { // 处理当前排列 } while (next_permutation(a, a + 3)); return 0; } ``` 如果想手动实现next_permutation函数,可以使用以下方法: ```cpp bool next_permutation(int* a, int n) { int i = n - 2; while (i >= 0 && a\[i\] >= a\[i + 1\]) { i--; } if (i < 0) { return false; } int j = n - 1; while (j >= 0 && a\[i\] >= a\[j\]) { j--; } swap(a\[i\], a\[j\]); reverse(a + i + 1, a + n); return true; } ``` 如果排列本来就是最大的,没有后继,那么在调用next_permutation函数后,会对排列进行字典升序排序,相当于循环。例如: ```cpp int list\[3\] = {3, 2, 1}; next_permutation(list, list + 3); cout << list\[0\] << " " << list\[1\] << " " << list\[2\] << endl; ``` 输出结果为 "1 2 3"。 #### 引用[.reference_title] - *1* *2* [C++中 next_permutation 函数的使用方法、原理及手动实现](https://blog.csdn.net/m0_51913750/article/details/130540032)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^koosearch_v1,239^v3^insert_chatgpt"}} ] [.reference_item] - *3* [next_permutation函数简要](https://blog.csdn.net/weixin_53518090/article/details/113345164)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^koosearch_v1,239^v3^insert_chatgpt"}} ] [.reference_item] [ .reference_list ]
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

m0_51864047

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值