関数型 Leetcode 百題 – 順列

WARNING

この記事 AI による中国語からの翻訳です。誤りがまれている可能性がありますので了承ください

序章

アルゴリズム問題、多くのプログラマにとっての悪夢つですアルゴリズム自体かに興味深いものですが、問題けない焦燥感面接えられないずかしさはもがまないものです

アルゴリズム問題への対処法としては問題 という伝統的方法があります。問題くとは、大量問題いてアルゴリズムぶことです。多くの人々、数学学習アルゴリズム学習、大量練習必要だとじています

もその理論賛成ですノイマン格言このブログトップページげられています

若者、数学では物事理解するのではなく、慣れるだけだ

直感的理解、直感的経験、無比価値財産です。練習はその貴重財産確実手段です。要するにアルゴリズムぶためには、問題、練習しなければなりません

しかし、私はあるアルゴリズム学習常道極度っていますそれはある問題けない10分間考えてからもがかりがつからないそこでえをすというですあなたはえを 理解 したり、答えをえておいたり、次問題てきたときにてはめたりするかもしれませんあなたがえが十分いならあなたが てはめる ことができる問題十分いでしょう

なぜはその学習法きではないのでしょうかはそれほどくないのかもしれません。私他人提供するえを 理解 するのがしいですあまりにもくの人々、最終的効率的アルゴリズムだけをえてくれ、直感形成過程アルゴリズム証明無視します。彼らのえから、彼らが問題する理解じさせられずまたアルゴリズム自体しさをじさせられません

たちは天才 けた教育じてしかアルゴリズムぶことができないのでしょうか

いいえ。世界には、純粋関数型言語使、等式推導方法プログラム設計しようとする人々がいます。彼らもまた直感価値大切にしています。彼らは、明確効率プログラムから出発、最終的効率的アルゴリズムへと一歩ずつみますその過程アルゴリズムしさアルゴリズム洞察やかにかびがります。故リチャード・バードRichard Bird台湾 信成 先生その手法先駆者です。彼らの論文、私しさをじました

、等式推導はしばしば非常困難であるかもしれませんがアルゴリズム non-trivial 部分であるかアルゴリズム直感であるかを明確説明することはそれほど困難ではないかもしれませんえています

このシリーズ記事、私みですこれらの記事では、私純粋関数型プログラミング使って Leetcode をどのようにくかを説明しますなぜ Leetcode んだのかより難易度 Codeforces ばなかったのかというと、難易度すぎることはずしもいことではないとえているからです。難易度問題しばしば複数アルゴリズム必要となる可能性があり、私たちは各記事特定アルゴリズム焦点てたいとっていますもうつの理由、私自身アルゴリズムしては 初心者 であるため、難易度すぎると対処できません

Leetcode んだ結果、私たちは Haskell ではなく Racket Scala 使ってコード提出しなければならないことがありますこれは時々問題こす可能性がありますが、私経験では、私提示するほとんどの Haskell コード Scala 直接翻訳できます。各問題して、私対応する Scala 提供しますもし問題じた場合、たとえば Scala Data.Graph という不変データ構造ライブラリがない場合など、私たちは状況により非純粋関数型プログラミングることもありますしかしそのような状況全体的にはないはずです。各問題最後には、対応する命令型バージョン提供するかもしれませんその場合、私たちは関数型バージョン命令型バージョンいについても議論するかもしれません

これらの記事 関数型プログラミング入門 ではありません。私はおそらくいくつかのライブラリ関数使すかもしれませんが、全体的にはこれらの記事はあなたがすでに純粋関数型プログラミング基本的知識っていることを前提としています 再帰、リストfoldmap などあなたが関数型プログラミング Haskell 言語についてらない場合、これらの記事いくつかの Haskell 知識ぶことをおめします。以下、私推奨する学習リソースです

、修業、上下めて

31. 順列

問題説明

以下Leetcode 説明です

整数配列 順列 とはそのすべてのメンバー一連または線形順序べることです

えばarr = [1,2,3] 場合、以下のようなものはすべて arr 順列なすことができます[1,2,3][1,3,2][3,1,2][2,3,1] 整数配列 順列 とはその整数辞書順きい順列しますより形式的えば、配列のすべての順列辞書順さいべられたコンテナ存在すると仮定するとその配列 順列 とはその順序付けられたコンテナでその配列直後存在する順列します。次きな順列存在しない場合、その配列辞書順最小順列つまりその要素昇順んでいる再配置する必要があります

えばarr = [1,2,3] 順列 [1,3,2] です 同様arr = [2,3,1] 順列 [3,1,2] です そして arr = [3,2,1] 順列 [1,2,3] ですなぜなら [3,2,1] 辞書順でよりきな順列存在しないからです 整数配列 nums えられた場合、nums 順列つけしてください

インプレース変更、追加定数スペースしか使用しないようにしてください

たちは純粋関数型プログラミングっています。純粋関数型プログラミングでは 変更 という概念存在しないため、最後無視します

最初

一般的、多くのアルゴリズム問題、非常簡潔しかし効率純粋関数型解えることができます。私たちはそのプログラム標準参照Spec、私たちの思考はそこからまりますしかしこの問題はそのような状況ではないようですこの問題数学的表現えます

next(a)={min(P)a=max(P)min({mmP,m>a})それ以外 \text{next}(a) = \begin{cases} \min(\mathbb{P}) & a = \max(\mathbb{P}) \\ \min(\{ m | m \in \mathbb{P}, m > a \}) & \text{それ以外} \end{cases}

これはたちにあまりインスピレーションえません。実際、多くのプログラミング言語ではP\mathbb{P}つまりあるリストのすべての順列めるのがこの問題よりも面倒場合があります1

そのような状況下、単純再帰 問題解決できるのでしょうか

再帰

順列関数next 、順列辞書順づいて定義されていますがそれはより一般的構造拡張される可能性があります

まず10進数整数状況検討します。明らかに10進数整数にも next 関数定義できますこれは大名鼎鼎 後続 関数すなわち f(x)=x+1f(x) = x + 1 です

10進数整数リストまたは、命令型言語配列として表現できますリスト各要素、数字各桁しますたとえば100100 [1, 0, 0] として表現されます

たちはリスト表現下後続関数注目しますなぜならそのような表現下では>= はちょうど辞書順比較であるからです。私たちはリスト表現下、10進数整数後続関数 next10 します

1-- next10 [1, 1, 0] = [1, 1, 1]
2next10 ...

たとえばnext10 [1, 1, 0] = [1, 1, 1] です

このたちに非常直感えますそれは next10 [1, 0] = [1, 1] のときnext10 [1, 1, 0] = 1 : next10 [1, 0] となることですこれを一般化するとx:xs 表現された整数して

1next10 (x:xs) = x:(next10 xs)

上記はほとんどの場合ちますただしnext10 xs められなくなる ときたとえば [1, 9, 9] 後続 [2, 0, 0] そのときには がり 必要となりますより厳密えば99 最大 210進数整数であるため、上記等式ちませんxs がまだ最大値でない、上記等式つことが証明できます

同様、問題められている next 関数同様性質つことが証明できますxs length xs 順列最大値でないとき

1next (x:xs) = x:next xs

この性質はあまりにも重要、私たちは直接再帰関数構築することができます

1next (x:xs)
2    | isMax xs  = tick x xs
3    | otherwise = x:next xs

それでは、問題2つになります

  1. isMax をどのように定義するか
  2. xs がすでに最大値である場合、tick 関数をどのように定義するか

isMax 定義

順列 xs して、「最大」どのように再配置しても、新しい xs' して xs >= xs' つことを意味します

どのような順列がそのような性質つのでしょうか直感的これは辞書順であるため、順列可能きくなるためには、大きなくべきだということです。言えれば、「降順」順列最大であるはずです

1isMax :: Ord a => [a] -> Bool
2isMax = down

Haskellリスト昇順または降順判断するには、「zip with tail方法使用できます。具体的にはリストとそのtailzipそのmapallなどの関数使用して判断します

1dup :: [a] -> [(a, a)]
2dup xs = zip xs (tail xs)
3
4down :: Ord a => [a] -> Bool
5down = all (uncurry (>=)) . dup

tick 定義

もし xs down たしている場合、言えればそれが最大である場合、x:xs 列挙変換するためには複雑操作必要となりますこの複雑操作、私tick名付けました

えば[2, 4, 3, 1]列挙[3, 1, 2, 4]ですこのときx2xs[4, 3, 1]xsdownたしているため、単純再帰ではなくtick使用して計算する必要があります

[2, 4, 3, 1]にとってみましょう。直感的[2, 4, 3, 1]列挙ys@(y:ys')るためには、次2つのステップけられます

  • yであるかを決定する
  • ys'であるかを決定する

まずyxより厳密きでなければなりませんなぜなら、私たちはすでにxまるすべての列挙x:xsよりさいことをしたからです。次y可能さくなければなりませんそうでなければpというよりさい列挙存在p > x:xsとなります

上記ではy3ですすぐにいつくのは3xs = [4, 3, 1]最小 x = 2よりきいです。私たちはこのようなpivotびます。定義コード以下します

1pivot x xs = minimum [ x' | x' <- xs, x' > x  ]

xs順序付けられていることに注意してください。上記コード以下のようにえることができます

1pivot x = last . takeWhile (> x)

指摘しておくべきはpivot関数部分関数であり、不安全lastminimum操作使用しているため、呼にはなくとも1つのx' > x存在することを保証しなければなりません

y > xつけることができればy:ys'x:xsよりきくなることを保証できますしたがってys'構築y:ys'可能さくするようにいます。言えればys'最小列挙でなければなりません。最大値についての上記議論同様、最小列挙単調増加列挙ですこれをうためにはソート必要です

ys'要素xsからyいた後、x追加したものです。例えば、上記[2, 4, 3, 1]では

1x:xs = [2, 4, 3, 1]
2
3y = last . takeWhile (> x) $ xs
4  = last [4, 3]
5  = 3
6
7ys'' = delete 3 xs
8     = [4, 1]
9
10ys' = sort (x:ys'')
11    = sort [2, 4, 1]
12    = [1, 2, 4]
13
14ys = y:ys'
15   = [3, 1, 2, 4]

最後pivot x xs = ⊥という状況について議論する必要がありますこの状況事前判断する必要がありますそうでなければエラー発生しますpivot存在しないのはいつでしょうかそれはx:xsdownたしているときです、例えば[4, 3, 2, 1]のときこのとき、入力反転するだけでみます

以上議論づきtick関数定義以下します

1import Data.List (delete)
2
3tick :: Ord a -> a -> [a] -> [a]
4tick x xs
5    | null l    = reverse (x:xs)
6    | otherwise = y:(sort (x:ys'))
7    where l = takeWhile (> x) xs
8          y = last l
9          ys' = delete y xs
1> tick 4 [3, 2, 1]
2[1, 2, 3, 4]
3
4> tick 2 [4, 3, 1]
5[3, 1, 2, 4]

いの余地なく、現在next関数はこの問題しく解決しています。読者上記HaskellコードScala翻訳Leetcodeしてみてください。再帰から出発この問題非常直感的、簡単提供できます

より効率的コード

上記定義したnext関数優雅直感的ですが、十分効率的ではありません。主問題

  1. tickではsort使用する必要はありませんxs順序付けられているためこの利用してしい順序付けられた列挙O(n)O(n)わせることができます
  2. downnextによって何度され、各呼しはO(n)O(n)のため、全体複雑度O(n2)O(n^2)になります

問題1.非常簡単tick再定義するだけでみます

問題2.複雑です。計算過程しく観察する必要があります、例えばnext [3, 5, 1, 4, 2]

1next [3, 5, 1, 4, 2]
2= 3:(next [5, 1, 4, 2])
3= 3:(5:(next [1, 4, 2]))
4= 3:(5:(tick 1 [4, 2]))

計算一種「形状」それは入力前半部分不変、後半部分tickされることです

1[3, 5,           1, 4, 2]
2^^^^^^           #######
3不変の部分     tickの部分

直感的にはその境界線downFalseからTrueわる瞬間です。再next関数ります

1next (x:xs)
2    | down xs  = tick x xs
3    | otherwise = x:next xs

各再帰のときnext (x:xs)down xs計算、先ほどの再利用します

1-- 第一次再帰
2down [5, 1, 4, 2] = False
3-- 第二次再帰
4down [1, 4, 2]    = False
5-- 第三次再帰
6down [4, 2]       = True

実際、downtails要素順序計算します

1> tails [3, 5, 1, 4, 2]
2[[3,5,1,4,2],[5,1,4,2],[1,4,2],[4,2],[2],[]]

next各回使用するdown xsをあらかじめ2計算リスト保存します

1import Data.List (tails)
2
3nextDown (x:xs) (d:ds)
4    | d = tick x xs
5    | otherwise = x:nextDown xs ds
6
7next xs = nextDown xs (downs xs)
8downs = drop 1 . map down . tails

もしdownsO(n)O(n)時間内計算できれば、問題2.解決します。直感的にはこれは非常簡単ですなぜならdown [4, 3, 2, 1]計算するときdown [3, 2, 1]はすでに計算されておりそれを「前回計算」保存、後使用する方法必要だからです

関数型プログラミングコミュニティではこの問題する解決方法存在します scan theorem.

Scan theorem以下します

1map (foldl op a) . inits = scanl op a

同様

1map (foldr op a) . tails = scanr op a

この定理利用して、以下プログラム計算過程します。注意、問題けるために、以下定義しました

1tails' = filter (not . null) . tails
1downs = { definition }
2        drop 1 . map down . tails'
3      = { definition }
4        drop 1 . map (all (uncurry (>=)) . dup) . tails'
5      = { map distributivity }
6        drop 1 . map (all (uncurry (>=))) . map dup . tails'
7      = { need another proof (1) }
8        drop 1 . map (all (uncurry (>=))) . tails . dup
9      = { definition }
10        drop 1 . map (foldr (&&) True . map (uncurry (>=))) . tails . dup
11      = { fold-map fusion }
12        drop 1 . map (foldr join True) . tails . dup
13      = { scan theorem }
14        drop 1 . scanr join True . dup
15          where join (x, x1) r = x >= x1 && r
  1. たな証明必要それは以下コード等価であることをしています
1> map dup $ tails' [4, 2, 3, 1]
2[[(4,2),(2,3),(3,1)],[(2,3),(3,1)],[(3,1)],[]]
3
4> tails $ dup [4, 2, 3, 1]
5[[(4,2),(2,3),(3,1)],[(2,3),(3,1)],[(3,1)],[]]

証明省略します

結論

上記議論わせることで、効率的関数型実装以下します

1tick x xs = build l r x
2    where (l, r) = span (> x) xs
3          build [] r x = reverse (x:r)
4          build l  r x = last l : reverse (init l ++ [x] ++ r)
5
6dup xs = zip xs (tail xs)
7downs = scanr (\(x, x1) r -> (x >= x1) && r) True . dup
8
9next xs = l ++ tick (head r) (tail r)
10    where (l, r) = splitAt (n - 1) xs
11          n = length $ takeWhile not $ downs xs

これはO(n)O(n)時間複雑度ですしかし、私たちの定数命令型プログラムよりもかにきいかもしれません。理論的には、前節定義したnextDown関数実装効率的かもしれませんlengthsplitAtけるしかし、私本節した実装がより明確だといます

等価Scalaコードleetcode提出しました

1object Solution {
2    def nextPermutation(nums: Array[Int]): Unit = {
3        val nextNums = next(nums.toList).toArray
4        for (i <- nums.indices) {
5            nums(i) = nextNums(i)
6        }
7    }
8
9    def dup(xs: List[Int]): List[(Int, Int)] = xs zip xs.drop(1)
10
11    def tick(x: Int, xs: List[Int]): List[Int] = {
12        val (l, r) = xs.span(_ > x)
13        if (l.isEmpty) r.reverse ++ List(x)
14        else l.last :: (l.init ++ List(x) ++ r).reverse
15    }
16
17    def tailsDown(xs: List[(Int, Int)]): List[Boolean] = 
18        xs.scanRight(true) { case ((x, x1), r) => (x >= x1) && r }
19
20    def next(xs: List[Int]): List[Int] = {
21        val n = tailsDown(dup(xs)).takeWhile(!_).length
22        val (l, r) = xs.splitAt(n - 1)
23        l ++ tick(r.head, r.tail)
24    }
25}

「次順列」問題、命令型プログラム関数型プログラムよりも簡潔になる数少ない問題つです。実際、HaskellData.Permuteライブラリ、命令型方法使用しています

命令型プログラムでは、上記アルゴリズム以下2つのプロセスとして説明できます

  1. から走査、最降順列a[i ... (len - 1)]つける
  2. 計算
    • i = 0場合、配列反転する
    • それ以外場合、j ∈ [i, (len - 1)]つけるそのような
      • a[j]>a[i1]a[j] > a[i - 1]
      • ki,a[k]>a[i1]a[k]a[j]\forall k \ge i, a[k] > a[i - 1] \to a[k] \ge a[j] i1,ji - 1, j 交換a[i(len - 1)]a[i \dots (\text{len - 1})] 反転する

上記関数型アルゴリズム完全理解している読者にとってはこの命令型アルゴリズム非常直感的のはずですしかしこのアルゴリズムをそのままあなたに提示、理解できるでしょうか

それではPythonプログラム今日めくくりましょう

1def swap(a, i, j):
2    tmp = a[i]
3    a[i] = a[j]
4    a[j] = tmp
5
6def rev(a, start, end):
7    l = end - start
8    for i in range(start, start + l // 2):
9        swap(a, i, end - (i - start) - 1)
10
11def solve(a):
12    for i in range(len(a) - 1, -1, -1):
13        if i >= 1 and a[i - 1] < a[i]:
14            break
15    if i == 0:
16        rev(a, 0, len(a))
17    else:
18        j = len(a) - 1
19        while j >= i and a[j] <= a[i - 1]:
20            j -= 1
21        swap(a, i - 1, j)
22        rev(a, i, len(a))